diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 187f500828c..4b956fd577a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -131,6 +131,7 @@ apps/web/src/translation-constants.ts @bitwarden/team-platform-dev .github/workflows/version-auto-bump.yml @bitwarden/team-platform-dev # ESLint custom rules libs/eslint @bitwarden/team-platform-dev +libs/eslint/components @bitwarden/team-ui-foundation # Typescript tooling tsconfig.base.json @bitwarden/team-platform-dev nx.json @bitwarden/team-platform-dev diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 906bbbd7125..e646049c3d6 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -197,6 +197,7 @@ "nx", "oo7", "oslog", + "parse5", "pin-project", "pkg", "postcss", diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index be140b9a20e..43661d50910 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -232,7 +232,7 @@ jobs: npm --version - name: Download browser source - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: browser-source-${{ env._BUILD_NUMBER }}.zip diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index 73b765f207a..22ba3a3e7be 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -499,7 +499,7 @@ jobs: echo "BW Package Version: $_PACKAGE_VERSION" - name: Get bw linux cli - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: bw-linux-${{ env._PACKAGE_VERSION }}.zip path: apps/cli/dist/snap diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 24caea44b85..2c512c52503 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -1081,7 +1081,7 @@ jobs: run: npm run build - name: Download Browser artifact - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: path: ${{ github.workspace }}/browser-build-artifacts @@ -1347,7 +1347,7 @@ jobs: run: npm run build - name: Download Browser artifact - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: path: ${{ github.workspace }}/browser-build-artifacts diff --git a/.github/workflows/release-desktop-beta.yml b/.github/workflows/release-desktop-beta.yml index 4d4bbc799c3..f5c5c2eb929 100644 --- a/.github/workflows/release-desktop-beta.yml +++ b/.github/workflows/release-desktop-beta.yml @@ -1037,7 +1037,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Download all artifacts - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: path: apps/desktop/artifacts diff --git a/.github/workflows/test-browser-interactions.yml b/.github/workflows/test-browser-interactions.yml index 872b4c35264..c6427b2e0d8 100644 --- a/.github/workflows/test-browser-interactions.yml +++ b/.github/workflows/test-browser-interactions.yml @@ -1,4 +1,5 @@ name: Autofill BIT checks +run-name: Autofill BIT checks on ${{ github.event.workflow_run.head_branch }} build on: workflow_run: @@ -11,10 +12,23 @@ jobs: name: Check files runs-on: ubuntu-22.04 permissions: - actions: read + actions: write contents: read id-token: write steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: Check for job requirements + if: ${{ !github.event.workflow_run.pull_requests || !github.event.workflow_run.head_branch }} + env: + GH_TOKEN: ${{ github.token }} + run: | + gh run cancel ${{ github.run_id }} + gh run watch ${{ github.run_id }} + - name: Log in to Azure uses: bitwarden/gh-actions/azure-login@main with: @@ -42,11 +56,6 @@ jobs: repositories: browser-interactions-testing permission-actions: write - - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - fetch-depth: 0 - - name: Get changed files id: changed-files uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a8bfd368884..64c4e0dff13 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -170,13 +170,13 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Download jest coverage - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: jest-coverage path: ./ - name: Download rust coverage - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: rust-coverage path: ./apps/desktop/desktop_native diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index f8dde376b35..1c27fab6971 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -5579,5 +5579,9 @@ }, "showLess": { "message": "Show less" + }, + "moreBreadcrumbs": { + "message": "More breadcrumbs", + "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." } } diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index 8bee4a4675d..691ea1870d0 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -156,7 +156,7 @@ describe("OverlayBackground", () => { fakeStateProvider = new FakeStateProvider(accountService); showFaviconsMock$ = new BehaviorSubject(true); neverDomainsMock$ = new BehaviorSubject({}); - domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider, configService); + domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); domainSettingsService.showFavicons$ = showFaviconsMock$; domainSettingsService.neverDomains$ = neverDomainsMock$; logService = mock(); diff --git a/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts b/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts index 901d6595fc8..1348928b7e9 100644 --- a/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts +++ b/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts @@ -2,6 +2,7 @@ import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, of } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { AUTOFILL_CARD_ID, AUTOFILL_ID, @@ -17,7 +18,6 @@ import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/s import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.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 { UserId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; @@ -67,7 +67,7 @@ const createCipher = (data?: { }; describe("context-menu", () => { - let stateService: MockProxy; + let tokenService: MockProxy; let autofillSettingsService: MockProxy; let i18nService: MockProxy; let logService: MockProxy; @@ -85,7 +85,7 @@ describe("context-menu", () => { let sut: MainContextMenuHandler; beforeEach(() => { - stateService = mock(); + tokenService = mock(); autofillSettingsService = mock(); i18nService = mock(); logService = mock(); @@ -109,7 +109,7 @@ describe("context-menu", () => { i18nService.t.mockImplementation((key) => key); sut = new MainContextMenuHandler( - stateService, + tokenService, autofillSettingsService, i18nService, logService, @@ -276,7 +276,7 @@ describe("context-menu", () => { it("removes menu items that require code injection", async () => { billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(true)); autofillSettingsService.enableContextMenu$ = of(true); - stateService.getIsAuthenticated.mockResolvedValue(true); + tokenService.hasAccessToken$.mockReturnValue(of(true)); const optionId = "1"; await sut.loadOptions("TEST_TITLE", optionId, createCipher()); @@ -317,7 +317,7 @@ describe("context-menu", () => { }); it("Loads context menu items that ask the user to unlock their vault if they are authed", async () => { - stateService.getIsAuthenticated.mockResolvedValue(true); + tokenService.hasAccessToken$.mockReturnValue(of(true)); await sut.noAccess(); @@ -325,7 +325,7 @@ describe("context-menu", () => { }); it("Loads context menu items that ask the user to login to their vault if they are not authed", async () => { - stateService.getIsAuthenticated.mockResolvedValue(false); + tokenService.hasAccessToken$.mockReturnValue(of(false)); await sut.noAccess(); diff --git a/apps/browser/src/autofill/browser/main-context-menu-handler.ts b/apps/browser/src/autofill/browser/main-context-menu-handler.ts index abfa2465c51..00ff55f5517 100644 --- a/apps/browser/src/autofill/browser/main-context-menu-handler.ts +++ b/apps/browser/src/autofill/browser/main-context-menu-handler.ts @@ -1,8 +1,9 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, map } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { AUTOFILL_CARD_ID, AUTOFILL_ID, @@ -23,7 +24,6 @@ import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/s import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.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 { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -152,7 +152,7 @@ export class MainContextMenuHandler { ]; constructor( - private stateService: StateService, + private tokenService: TokenService, private autofillSettingsService: AutofillSettingsServiceAbstraction, private i18nService: I18nService, private logService: LogService, @@ -343,7 +343,11 @@ export class MainContextMenuHandler { async noAccess() { if (await this.init()) { - const authed = await this.stateService.getIsAuthenticated(); + const userId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); + const authed = + userId != null && (await firstValueFrom(this.tokenService.hasAccessToken$(userId))); this.loadOptions( this.i18nService.t(authed ? "unlockVaultMenu" : "loginToVaultMenu"), NOOP_COMMAND_SUFFIX, diff --git a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts index 8a3a7e6fa8d..62f9dbec824 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts @@ -29,7 +29,7 @@ describe("AutofillInlineMenuContentService", () => { autofillInit = new AutofillInit( domQueryService, domElementVisibilityService, - null, + undefined, autofillInlineMenuContentService, ); autofillInit.init(); @@ -319,6 +319,8 @@ describe("AutofillInlineMenuContentService", () => { describe("handleContainerElementMutationObserverUpdate", () => { let mockMutationRecord: MockProxy; + let mockBodyMutationRecord: MockProxy; + let mockHTMLMutationRecord: MockProxy; let buttonElement: HTMLElement; let listElement: HTMLElement; let isInlineMenuListVisibleSpy: jest.SpyInstance; @@ -329,6 +331,16 @@ describe("AutofillInlineMenuContentService", () => {
`; mockMutationRecord = mock({ target: globalThis.document.body } as any); + mockHTMLMutationRecord = mock({ + target: globalThis.document.body.parentElement, + attributeName: "style", + type: "attributes", + } as any); + mockBodyMutationRecord = mock({ + target: globalThis.document.body, + attributeName: "style", + type: "attributes", + } as any); buttonElement = document.querySelector(".overlay-button") as HTMLElement; listElement = document.querySelector(".overlay-list") as HTMLElement; autofillInlineMenuContentService["buttonElement"] = buttonElement; @@ -343,6 +355,7 @@ describe("AutofillInlineMenuContentService", () => { "isTriggeringExcessiveMutationObserverIterations", ) .mockReturnValue(false); + jest.spyOn(autofillInlineMenuContentService as any, "closeInlineMenu"); }); it("skips handling the mutation if the overlay elements are not present in the DOM", async () => { @@ -373,6 +386,33 @@ describe("AutofillInlineMenuContentService", () => { expect(globalThis.document.body.insertBefore).not.toHaveBeenCalled(); }); + it("closes the inline menu if the page body is not sufficiently opaque", async () => { + document.querySelector("html").style.opacity = "0.9"; + document.body.style.opacity = "0"; + autofillInlineMenuContentService["handlePageMutations"]([mockBodyMutationRecord]); + + expect(autofillInlineMenuContentService["pageIsOpaque"]).toBe(false); + expect(autofillInlineMenuContentService["closeInlineMenu"]).toHaveBeenCalled(); + }); + + it("closes the inline menu if the page html is not sufficiently opaque", async () => { + document.querySelector("html").style.opacity = "0.3"; + document.body.style.opacity = "0.7"; + autofillInlineMenuContentService["handlePageMutations"]([mockHTMLMutationRecord]); + + expect(autofillInlineMenuContentService["pageIsOpaque"]).toBe(false); + expect(autofillInlineMenuContentService["closeInlineMenu"]).toHaveBeenCalled(); + }); + + it("does not close the inline menu if the page html and body is sufficiently opaque", async () => { + document.querySelector("html").style.opacity = "0.9"; + document.body.style.opacity = "1"; + autofillInlineMenuContentService["handlePageMutations"]([mockBodyMutationRecord]); + + expect(autofillInlineMenuContentService["pageIsOpaque"]).toBe(true); + expect(autofillInlineMenuContentService["closeInlineMenu"]).not.toHaveBeenCalled(); + }); + it("skips re-arranging the DOM elements if the last child of the body is non-existent", async () => { document.body.innerHTML = ""; diff --git a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts index 9bdaf0f965b..de401bf7e28 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts @@ -29,8 +29,11 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte private isFirefoxBrowser = globalThis.navigator.userAgent.indexOf(" Firefox/") !== -1 || globalThis.navigator.userAgent.indexOf(" Gecko/") !== -1; - private buttonElement: HTMLElement; - private listElement: HTMLElement; + private buttonElement?: HTMLElement; + private listElement?: HTMLElement; + private htmlMutationObserver: MutationObserver; + private bodyMutationObserver: MutationObserver; + private pageIsOpaque = true; private inlineMenuElementsMutationObserver: MutationObserver; private containerElementMutationObserver: MutationObserver; private mutationObserverIterations = 0; @@ -49,6 +52,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte }; constructor() { + this.checkPageOpacity(); this.setupMutationObserver(); } @@ -281,6 +285,9 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * that the inline menu elements are always present at the bottom of the menu container. */ private setupMutationObserver = () => { + this.htmlMutationObserver = new MutationObserver(this.handlePageMutations); + this.bodyMutationObserver = new MutationObserver(this.handlePageMutations); + this.inlineMenuElementsMutationObserver = new MutationObserver( this.handleInlineMenuElementMutationObserverUpdate, ); @@ -295,6 +302,9 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * elements are not modified by the website. */ private observeCustomElements() { + this.htmlMutationObserver?.observe(document.querySelector("html"), { attributes: true }); + this.bodyMutationObserver?.observe(document.body, { attributes: true }); + if (this.buttonElement) { this.inlineMenuElementsMutationObserver?.observe(this.buttonElement, { attributes: true, @@ -395,11 +405,56 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte }); }; + private checkPageOpacity = () => { + this.pageIsOpaque = this.getPageIsOpaque(); + + if (!this.pageIsOpaque) { + this.closeInlineMenu(); + } + }; + + private handlePageMutations = (mutations: MutationRecord[]) => { + for (const mutation of mutations) { + if (mutation.type === "attributes") { + this.checkPageOpacity(); + } + } + }; + + /** + * Checks the opacity of the page body and body parent, since the inline menu experience + * will inherit the opacity, despite being otherwise encapsulated from styling changes + * of parents below the body. Assumes the target element will be a direct child of the page + * `body` (enforced elsewhere). + */ + private getPageIsOpaque() { + // These are computed style values, so we don't need to worry about non-float values + // for `opacity`, here + const htmlOpacity = globalThis.window.getComputedStyle( + globalThis.document.querySelector("html"), + ).opacity; + const bodyOpacity = globalThis.window.getComputedStyle( + globalThis.document.querySelector("body"), + ).opacity; + + // Any value above this is considered "opaque" for our purposes + const opacityThreshold = 0.6; + + return parseFloat(htmlOpacity) > opacityThreshold && parseFloat(bodyOpacity) > opacityThreshold; + } + /** * Processes the mutation of the element that contains the inline menu. Will trigger when an * idle moment in the execution of the main thread is detected. */ private processContainerElementMutation = async (containerElement: HTMLElement) => { + // If the computed opacity of the body and parent is not sufficiently opaque, tear + // down and prevent building the inline menu experience. + this.checkPageOpacity(); + if (!this.pageIsOpaque) { + return; + } + const lastChild = containerElement.lastElementChild; const secondToLastChild = lastChild?.previousElementSibling; const lastChildIsInlineMenuList = lastChild === this.listElement; diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.html b/apps/browser/src/autofill/popup/settings/autofill.component.html index 985a0962c95..9ba27d87ece 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.html +++ b/apps/browser/src/autofill/popup/settings/autofill.component.html @@ -213,7 +213,7 @@ - +

{{ "additionalOptions" | i18n }}

@@ -276,7 +276,7 @@
- + {{ "blockedDomains" | i18n }} diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.ts b/apps/browser/src/autofill/popup/settings/autofill.component.ts index dcce3f37368..3d2d605c13f 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.ts +++ b/apps/browser/src/autofill/popup/settings/autofill.component.ts @@ -44,7 +44,6 @@ import { DisablePasswordManagerUri, InlineMenuVisibilitySetting, } from "@bitwarden/common/autofill/types"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { UriMatchStrategy, UriMatchStrategySetting, @@ -110,7 +109,6 @@ export class AutofillComponent implements OnInit { protected defaultBrowserAutofillDisabled: boolean = false; protected inlineMenuVisibility: InlineMenuVisibilitySetting = AutofillOverlayVisibility.OnFieldFocus; - protected blockBrowserInjectionsByDomainEnabled: boolean = false; protected browserClientVendor: BrowserClientVendor = BrowserClientVendors.Unknown; protected disablePasswordManagerURI: DisablePasswordManagerUri = DisablePasswordManagerUris.Unknown; @@ -222,10 +220,6 @@ export class AutofillComponent implements OnInit { this.autofillSettingsService.inlineMenuVisibility$, ); - this.blockBrowserInjectionsByDomainEnabled = await this.configService.getFeatureFlag( - FeatureFlag.BlockBrowserInjectionsByDomain, - ); - this.showInlineMenuIdentities = await firstValueFrom( this.autofillSettingsService.showInlineMenuIdentities$, ); diff --git a/apps/browser/src/autofill/popup/settings/blocked-domains.component.html b/apps/browser/src/autofill/popup/settings/blocked-domains.component.html index 8156525301b..6a08b4483af 100644 --- a/apps/browser/src/autofill/popup/settings/blocked-domains.component.html +++ b/apps/browser/src/autofill/popup/settings/blocked-domains.component.html @@ -30,7 +30,7 @@ diff --git a/apps/browser/src/platform/popup/layout/popup-back.directive.ts b/apps/browser/src/platform/popup/layout/popup-back.directive.ts index ce8ebff5ec5..62d66ab87e5 100644 --- a/apps/browser/src/platform/popup/layout/popup-back.directive.ts +++ b/apps/browser/src/platform/popup/layout/popup-back.directive.ts @@ -1,8 +1,6 @@ -import { Directive, Optional } from "@angular/core"; +import { Directive, inject, model } from "@angular/core"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; -import { BitActionDirective, ButtonLikeAbstraction } from "@bitwarden/components"; +import { BitActionDirective, FunctionReturningAwaitable } from "@bitwarden/components"; import { PopupRouterCacheService } from "../view-cache/popup-router-cache.service"; @@ -11,15 +9,10 @@ import { PopupRouterCacheService } from "../view-cache/popup-router-cache.servic selector: "[popupBackAction]", }) export class PopupBackBrowserDirective extends BitActionDirective { - constructor( - buttonComponent: ButtonLikeAbstraction, - private router: PopupRouterCacheService, - @Optional() validationService?: ValidationService, - @Optional() logService?: LogService, - ) { - super(buttonComponent, validationService, logService); - - // override `bitAction` input; the parent handles the rest - this.handler.set(() => this.router.back()); - } + private routerCacheService = inject(PopupRouterCacheService); + // Override the required input to make it optional since we set it automatically + override readonly handler = model( + () => this.routerCacheService.back(), + { alias: "popupBackAction" }, + ); } diff --git a/apps/browser/src/platform/popup/layout/popup-header.component.html b/apps/browser/src/platform/popup/layout/popup-header.component.html index 014ebc86411..2aac161b9d5 100644 --- a/apps/browser/src/platform/popup/layout/popup-header.component.html +++ b/apps/browser/src/platform/popup/layout/popup-header.component.html @@ -19,8 +19,7 @@ bitIconButton="bwi-angle-left" type="button" *ngIf="showBackButton" - [title]="'back' | i18n" - [attr.aria-label]="'back' | i18n" + [label]="'back' | i18n" [bitAction]="backAction" >

diff --git a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts index e7f157328bb..7455921b08b 100644 --- a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts +++ b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts @@ -67,14 +67,10 @@ class ExtensionPoppedContainerComponent {} - + - + @@ -102,13 +98,7 @@ class MockAddButtonComponent {} @Component({ selector: "mock-popout-button", template: ` - + `, imports: [IconButtonModule], }) @@ -278,7 +268,13 @@ class MockSettingsPageComponent {} - + `, @@ -343,7 +339,7 @@ export default { generator: "Generator", send: "Send", settings: "Settings", - labelWithNotification: (label: string) => `${label}: New Notification`, + labelWithNotification: (label: string | undefined) => `${label}: New Notification`, }); }, }, @@ -671,17 +667,13 @@ export const WithVirtualScrollChild: Story = { - + diff --git a/apps/browser/src/platform/services/browser-script-injector.service.spec.ts b/apps/browser/src/platform/services/browser-script-injector.service.spec.ts index b177497305b..21f6debc02f 100644 --- a/apps/browser/src/platform/services/browser-script-injector.service.spec.ts +++ b/apps/browser/src/platform/services/browser-script-injector.service.spec.ts @@ -1,11 +1,10 @@ -import { mock, MockProxy } from "jest-mock-extended"; +import { mock } from "jest-mock-extended"; import { of } from "rxjs"; import { DomainSettingsService, DefaultDomainSettingsService, } from "@bitwarden/common/autofill/services/domain-settings.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -54,14 +53,11 @@ describe("ScriptInjectorService", () => { const mockUserId = Utils.newGuid() as UserId; const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); const fakeStateProvider: FakeStateProvider = new FakeStateProvider(accountService); - let configService: MockProxy; let domainSettingsService: DomainSettingsService; beforeEach(() => { jest.spyOn(BrowserApi, "getTab").mockImplementation(async () => tabMock); - configService = mock(); - configService.getFeatureFlag$.mockImplementation(() => of(false)); - domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider, configService); + domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains); domainSettingsService.blockedInteractionsUris$ = of({}); scriptInjectorService = new BrowserScriptInjectorService( diff --git a/apps/browser/src/platform/sync/foreground-sync.service.spec.ts b/apps/browser/src/platform/sync/foreground-sync.service.spec.ts index 34ee4fa0f77..f8b4050a5ce 100644 --- a/apps/browser/src/platform/sync/foreground-sync.service.spec.ts +++ b/apps/browser/src/platform/sync/foreground-sync.service.spec.ts @@ -4,8 +4,8 @@ import { Subject } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { MessageListener, MessageSender } from "@bitwarden/common/platform/messaging"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SyncOptions } from "@bitwarden/common/platform/sync/sync.service"; @@ -22,7 +22,7 @@ import { FullSyncFinishedMessage } from "./sync-service.listener"; describe("ForegroundSyncService", () => { const userId = Utils.newGuid() as UserId; - const stateService = mock(); + const tokenService = mock(); const folderService = mock(); const folderApiService = mock(); const messageSender = mock(); @@ -38,7 +38,7 @@ describe("ForegroundSyncService", () => { const stateProvider = new FakeStateProvider(accountService); const sut = new ForegroundSyncService( - stateService, + tokenService, folderService, folderApiService, messageSender, diff --git a/apps/browser/src/platform/sync/foreground-sync.service.ts b/apps/browser/src/platform/sync/foreground-sync.service.ts index 2ac75bbec2c..01b1f35239b 100644 --- a/apps/browser/src/platform/sync/foreground-sync.service.ts +++ b/apps/browser/src/platform/sync/foreground-sync.service.ts @@ -4,8 +4,8 @@ import { CollectionService } from "@bitwarden/admin-console/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { CommandDefinition, MessageListener, @@ -31,7 +31,7 @@ export const DO_FULL_SYNC = new CommandDefinition("doFullSync") export class ForegroundSyncService extends CoreSyncService { constructor( - stateService: StateService, + tokenService: TokenService, folderService: InternalFolderService, folderApiService: FolderApiServiceAbstraction, messageSender: MessageSender, @@ -47,7 +47,7 @@ export class ForegroundSyncService extends CoreSyncService { stateProvider: StateProvider, ) { super( - stateService, + tokenService, folderService, folderApiService, messageSender, diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index 6a26476de43..fa1e6c237c9 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -28,13 +28,13 @@ import { DocumentLangSetter } from "@bitwarden/angular/platform/i18n"; import { LogoutReason, UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AnimationControlService } from "@bitwarden/common/platform/abstractions/animation-control.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 { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { MessageListener } from "@bitwarden/common/platform/messaging"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -102,7 +102,7 @@ export class AppComponent implements OnInit, OnDestroy { private authService: AuthService, private i18nService: I18nService, private router: Router, - private stateService: StateService, + private readonly tokenService: TokenService, private vaultBrowserStateService: VaultBrowserStateService, private cipherService: CipherService, private changeDetectorRef: ChangeDetectorRef, @@ -321,7 +321,7 @@ export class AppComponent implements OnInit, OnDestroy { } private async clearComponentStates() { - if (!(await this.stateService.getIsAuthenticated())) { + if (!(await firstValueFrom(this.tokenService.hasAccessToken$(this.activeUserId)))) { return; } diff --git a/apps/browser/src/popup/services/init.service.ts b/apps/browser/src/popup/services/init.service.ts index 9e750ae7341..1930dbd1d4b 100644 --- a/apps/browser/src/popup/services/init.service.ts +++ b/apps/browser/src/popup/services/init.service.ts @@ -8,6 +8,7 @@ import { LogService as LogServiceAbstraction } from "@bitwarden/common/platform/ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { BrowserApi } from "../../platform/browser/browser-api"; import BrowserPopupUtils from "../../platform/browser/browser-popup-utils"; @@ -27,13 +28,14 @@ export class InitService { private themingService: AbstractThemingService, private sdkLoadService: SdkLoadService, private viewCacheService: PopupViewCacheService, + private readonly migrationRunner: MigrationRunner, @Inject(DOCUMENT) private document: Document, ) {} init() { return async () => { await this.sdkLoadService.loadAndInit(); - await this.stateService.init({ runMigrations: false }); // Browser background is responsible for migrations + await this.migrationRunner.waitForCompletion(); // Browser background is responsible for migrations await this.i18nService.init(); this.twoFactorService.init(); await this.viewCacheService.init(); diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 7c67e672889..f531ebd5ca7 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -47,6 +47,7 @@ import { import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { AutofillSettingsService, @@ -62,6 +63,7 @@ import { } from "@bitwarden/common/autofill/services/user-notification-settings.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { ClientType } from "@bitwarden/common/enums"; +import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypto/services/web-crypto-function.service"; @@ -79,7 +81,6 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -333,7 +334,7 @@ const safeProviders: SafeProvider[] = [ provide: SyncService, useClass: ForegroundSyncService, deps: [ - StateService, + TokenService, InternalFolderService, FolderApiServiceAbstraction, MessageSender, @@ -352,7 +353,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: DomainSettingsService, useClass: DefaultDomainSettingsService, - deps: [StateProvider, ConfigService], + deps: [StateProvider], }), safeProvider({ provide: AbstractStorageService, diff --git a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html index 5d313188d8f..c6ea52aff62 100644 --- a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html +++ b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html @@ -26,7 +26,7 @@ slot="end" bitIconButton="bwi-trash" [bitAction]="deleteSend" - appA11yTitle="{{ 'delete' | i18n }}" + label="{{ 'delete' | i18n }}" > diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html index 21b298fb30a..8f184c6a0c1 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html @@ -38,7 +38,7 @@ type="button" buttonType="danger" bitIconButton="bwi-trash" - [appA11yTitle]="'delete' | i18n" + [label]="'delete' | i18n" > diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html index 567d5277454..f4cc27171ad 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html @@ -7,7 +7,7 @@ size="small" appCopyField="username" [cipher]="cipher" - [appA11yTitle]="'copyUsername' | i18n" + [label]="'copyUsername' | i18n" > @@ -18,7 +18,7 @@ size="small" appCopyField="password" [cipher]="cipher" - [appA11yTitle]="'copyPassword' | i18n" + [label]="'copyPassword' | i18n" > @@ -28,7 +28,7 @@ size="small" appCopyField="totp" [cipher]="cipher" - [appA11yTitle]="'copyVerificationCode' | i18n" + [label]="'copyVerificationCode' | i18n" > @@ -40,7 +40,7 @@ type="button" bitIconButton="bwi-clone" size="small" - [appA11yTitle]="'copyFieldCipherName' | i18n: singleCopyableLogin.key : cipher.name" + [label]="'copyFieldCipherName' | i18n: singleCopyableLogin.key : cipher.name" [appCopyField]="singleCopyableLogin.field" [cipher]="cipher" > @@ -49,7 +49,7 @@ type="button" bitIconButton="bwi-clone" size="small" - [appA11yTitle]=" + [label]=" hasLoginValues ? ('copyInfoTitle' | i18n: cipher.name) : ('noValuesToCopy' | i18n) " [disabled]="!hasLoginValues" @@ -86,7 +86,7 @@ size="small" appCopyField="cardNumber" [cipher]="cipher" - [appA11yTitle]="'copyNumber' | i18n" + [label]="'copyNumber' | i18n" > @@ -96,7 +96,7 @@ size="small" appCopyField="securityCode" [cipher]="cipher" - [appA11yTitle]="'copySecurityCode' | i18n" + [label]="'copySecurityCode' | i18n" > @@ -107,7 +107,7 @@ type="button" bitIconButton="bwi-clone" size="small" - [appA11yTitle]="'copyFieldCipherName' | i18n: singleCopyableCard.key : cipher.name" + [label]="'copyFieldCipherName' | i18n: singleCopyableCard.key : cipher.name" [appCopyField]="singleCopyableCard.field" [cipher]="cipher" showToast @@ -117,7 +117,7 @@ type="button" bitIconButton="bwi-clone" size="small" - [appA11yTitle]=" + [label]=" hasCardValues ? ('copyInfoTitle' | i18n: cipher.name) : ('noValuesToCopy' | i18n) " [disabled]="!hasCardValues" @@ -142,7 +142,7 @@ type="button" bitIconButton="bwi-clone" size="small" - [appA11yTitle]="'copyFieldCipherName' | i18n: singleCopyableIdentity.key : cipher.name" + [label]="'copyFieldCipherName' | i18n: singleCopyableIdentity.key : cipher.name" [appCopyField]="singleCopyableIdentity.field" [cipher]="cipher" showToast @@ -152,7 +152,7 @@ type="button" bitIconButton="bwi-clone" size="small" - [appA11yTitle]=" + [label]=" hasIdentityValues ? ('copyInfoTitle' | i18n: cipher.name) : ('noValuesToCopy' | i18n) " [disabled]="!hasIdentityValues" @@ -180,9 +180,7 @@ type="button" bitIconButton="bwi-clone" size="small" - [appA11yTitle]=" - hasSecureNoteValue ? ('copyNoteTitle' | i18n: cipher.name) : ('noValuesToCopy' | i18n) - " + [label]="hasSecureNoteValue ? ('copyNoteTitle' | i18n: cipher.name) : ('noValuesToCopy' | i18n)" appCopyField="secureNote" [cipher]="cipher" > @@ -193,9 +191,7 @@ type="button" bitIconButton="bwi-clone" size="small" - [appA11yTitle]=" - hasSshKeyValues ? ('copyInfoTitle' | i18n: cipher.name) : ('noValuesToCopy' | i18n) - " + [label]="hasSshKeyValues ? ('copyInfoTitle' | i18n: cipher.name) : ('noValuesToCopy' | i18n)" [disabled]="!hasSshKeyValues" [bitMenuTriggerFor]="sshKeyOptions" > 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 962f0c914f5..42e2779679a 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 @@ -3,8 +3,7 @@ type="button" bitIconButton="bwi-ellipsis-v" size="small" - [attr.aria-label]="'moreOptionsLabel' | i18n: cipher.name" - [title]="'moreOptionsTitle' | i18n: cipher.name" + [label]="'moreOptionsLabel' | i18n: cipher.name" [disabled]="decryptionFailure" [bitMenuTriggerFor]="moreOptions" > diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.html index 91feaa433a9..1ab162b56fb 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.html @@ -8,7 +8,7 @@ bitIconButton="bwi-sliders" [buttonType]="'muted'" [bitDisclosureTriggerFor]="disclosureRef" - [appA11yTitle]="'filterVault' | i18n" + [label]="'filterVault' | i18n" aria-describedby="filters-applied" >

diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html index 8c76db600ae..9b8380a4214 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html @@ -33,7 +33,7 @@ type="button" buttonType="danger" bitIconButton="bwi-trash" - [appA11yTitle]="(cipher.isDeleted ? 'deleteForever' : 'delete') | i18n" + [label]="(cipher.isDeleted ? 'deleteForever' : 'delete') | i18n" > diff --git a/apps/browser/src/vault/popup/settings/folders-v2.component.html b/apps/browser/src/vault/popup/settings/folders-v2.component.html index 8cea05f9c17..b36b5affc23 100644 --- a/apps/browser/src/vault/popup/settings/folders-v2.component.html +++ b/apps/browser/src/vault/popup/settings/folders-v2.component.html @@ -25,7 +25,7 @@ slot="end" type="button" (click)="openAddEditFolderDialog(folder)" - [appA11yTitle]="'editFolderWithName' | i18n: folder.name" + [label]="'editFolderWithName' | i18n: folder.name" bitIconButton="bwi-pencil-square" class="tw-self-end" data-testid="edit-folder-button" diff --git a/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.html b/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.html index 11ed2674178..d1e70390844 100644 --- a/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.html +++ b/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.html @@ -37,8 +37,7 @@ type="button" bitIconButton="bwi-ellipsis-v" size="small" - [attr.aria-label]="'moreOptionsLabel' | i18n: cipher.name" - [title]="'moreOptionsTitle' | i18n: cipher.name" + [label]="'moreOptionsLabel' | i18n: cipher.name" [bitMenuTriggerFor]="moreOptions" > diff --git a/apps/cli/src/base-program.ts b/apps/cli/src/base-program.ts index 5719f78c1b9..5957f08de89 100644 --- a/apps/cli/src/base-program.ts +++ b/apps/cli/src/base-program.ts @@ -129,7 +129,7 @@ export abstract class BaseProgram { if (!userId) { fail(); } - const authed = await this.serviceContainer.stateService.getIsAuthenticated({ userId }); + const authed = await firstValueFrom(this.serviceContainer.tokenService.hasAccessToken$(userId)); if (!authed) { fail(); } diff --git a/apps/cli/src/oss-serve-configurator.ts b/apps/cli/src/oss-serve-configurator.ts index df46e22f84d..6ae2776eae7 100644 --- a/apps/cli/src/oss-serve-configurator.ts +++ b/apps/cli/src/oss-serve-configurator.ts @@ -107,7 +107,8 @@ export class OssServeConfigurator { ); this.generateCommand = new GenerateCommand( this.serviceContainer.passwordGenerationService, - this.serviceContainer.stateService, + this.serviceContainer.tokenService, + this.serviceContainer.accountService, ); this.syncCommand = new SyncCommand(this.serviceContainer.syncService); this.statusCommand = new StatusCommand( @@ -417,14 +418,18 @@ export class OssServeConfigurator { } protected async errorIfLocked(res: koa.Response) { - const authed = await this.serviceContainer.stateService.getIsAuthenticated(); + const userId = await firstValueFrom( + this.serviceContainer.accountService.activeAccount$.pipe(map((account) => account?.id)), + ); + + const authed = + userId != null || + (await firstValueFrom(this.serviceContainer.tokenService.hasAccessToken$(userId))); + if (!authed) { this.processResponse(res, Response.error("You are not logged in.")); return true; } - const userId = await firstValueFrom( - this.serviceContainer.accountService.activeAccount$.pipe(map((account) => account?.id)), - ); if (await this.serviceContainer.keyService.hasUserKey(userId)) { return false; } diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index 468901282b4..4d541739aab 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -2,7 +2,7 @@ // @ts-strict-ignore import * as chalk from "chalk"; import { program, Command, OptionValues } from "commander"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, of, switchMap } from "rxjs"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; @@ -129,7 +129,17 @@ export class Program extends BaseProgram { "Path to a file containing your password as its first line", ) .option("--check", "Check login status.", async () => { - const authed = await this.serviceContainer.stateService.getIsAuthenticated(); + const authed = await firstValueFrom( + this.serviceContainer.accountService.activeAccount$.pipe( + switchMap((account) => { + if (account == null) { + return of(false); + } + + return this.serviceContainer.tokenService.hasAccessToken$(account.id); + }), + ), + ); if (authed) { const res = new MessageResponse("You are logged in!", null); this.processResponse(Response.success(res), true); @@ -350,7 +360,8 @@ export class Program extends BaseProgram { .action(async (options) => { const command = new GenerateCommand( this.serviceContainer.passwordGenerationService, - this.serviceContainer.stateService, + this.serviceContainer.tokenService, + this.serviceContainer.accountService, ); const response = await command.run(options); this.processResponse(response); diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 0ec24768b79..e82ceb5a6e9 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -60,6 +60,10 @@ import { import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; import { ClientType } from "@bitwarden/common/enums"; +import { + DefaultKeyGenerationService, + KeyGenerationService, +} from "@bitwarden/common/key-management/crypto"; import { EncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/encrypt.service.implementation"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation"; @@ -81,14 +85,10 @@ import { EnvironmentService, RegionConfig, } from "@bitwarden/common/platform/abstractions/environment.service"; -import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { KeySuffixOptions, LogLevelType } from "@bitwarden/common/platform/enums"; -import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { MessageSender } from "@bitwarden/common/platform/messaging"; -import { Account } from "@bitwarden/common/platform/models/domain/account"; -import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { TaskSchedulerService, DefaultTaskSchedulerService, @@ -99,23 +99,23 @@ import { DefaultConfigService } from "@bitwarden/common/platform/services/config import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { DefaultEnvironmentService } from "@bitwarden/common/platform/services/default-environment.service"; import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service"; -import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.service"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { DefaultSdkClientFactory } from "@bitwarden/common/platform/services/sdk/default-sdk-client-factory"; import { DefaultSdkService } from "@bitwarden/common/platform/services/sdk/default-sdk.service"; import { NoopSdkClientFactory } from "@bitwarden/common/platform/services/sdk/noop-sdk-client-factory"; -import { StateService } from "@bitwarden/common/platform/services/state.service"; import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider"; import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service"; import { ActiveUserStateProvider, + DefaultStateService, DerivedStateProvider, GlobalStateProvider, SingleUserStateProvider, StateEventRunnerService, StateProvider, + StateService, } from "@bitwarden/common/platform/state"; /* eslint-disable import/no-restricted-paths -- We need the implementation to inject, but generally these should not be accessed */ import { DefaultActiveUserStateProvider } from "@bitwarden/common/platform/state/implementations/default-active-user-state.provider"; @@ -210,6 +210,7 @@ export class ServiceContainer { secureStorageService: NodeEnvSecureStorageService; memoryStorageService: MemoryStorageService; memoryStorageForStateProviders: MemoryStorageServiceForStateProviders; + migrationRunner: MigrationRunner; i18nService: I18nService; platformUtilsService: CliPlatformUtilsService; keyService: KeyService; @@ -239,7 +240,7 @@ export class ServiceContainer { individualExportService: IndividualVaultExportServiceAbstraction; organizationExportService: OrganizationVaultExportServiceAbstraction; searchService: SearchService; - keyGenerationService: KeyGenerationServiceAbstraction; + keyGenerationService: KeyGenerationService; cryptoFunctionService: NodeCryptoFunctionService; encryptService: EncryptServiceImplementation; authService: AuthService; @@ -377,8 +378,10 @@ export class ServiceContainer { this.singleUserStateProvider, ); + const activeUserAccessor = new DefaultActiveUserAccessor(this.accountService); + this.activeUserStateProvider = new DefaultActiveUserStateProvider( - new DefaultActiveUserAccessor(this.accountService), + activeUserAccessor, this.singleUserStateProvider, ); @@ -397,7 +400,7 @@ export class ServiceContainer { process.env.ADDITIONAL_REGIONS as unknown as RegionConfig[], ); - this.keyGenerationService = new KeyGenerationService(this.cryptoFunctionService); + this.keyGenerationService = new DefaultKeyGenerationService(this.cryptoFunctionService); this.tokenService = new TokenService( this.singleUserStateProvider, @@ -410,23 +413,17 @@ export class ServiceContainer { logoutCallback, ); - const migrationRunner = new MigrationRunner( + this.migrationRunner = new MigrationRunner( this.storageService, this.logService, new MigrationBuilderService(), ClientType.Cli, ); - this.stateService = new StateService( + this.stateService = new DefaultStateService( this.storageService, this.secureStorageService, - this.memoryStorageService, - this.logService, - new StateFactory(GlobalState, Account), - this.accountService, - this.environmentService, - this.tokenService, - migrationRunner, + activeUserAccessor, ); this.kdfConfigService = new DefaultKdfConfigService(this.stateProvider); @@ -530,10 +527,7 @@ export class ServiceContainer { this.authService, ); - this.domainSettingsService = new DefaultDomainSettingsService( - this.stateProvider, - this.configService, - ); + this.domainSettingsService = new DefaultDomainSettingsService(this.stateProvider); this.fileUploadService = new FileUploadService(this.logService, this.apiService); @@ -714,7 +708,6 @@ export class ServiceContainer { this.apiService, this.i18nService, this.searchService, - this.stateService, this.autofillSettingsService, this.encryptService, this.cipherFileUploadService, @@ -765,6 +758,7 @@ export class ServiceContainer { this.messagingService, this.searchService, this.stateService, + this.tokenService, this.authService, this.vaultTimeoutSettingsService, this.stateEventRunnerService, @@ -791,7 +785,6 @@ export class ServiceContainer { this.sendService, this.logService, this.keyConnectorService, - this.stateService, this.providerService, this.folderApiService, this.organizationService, @@ -904,7 +897,8 @@ export class ServiceContainer { await this.stateEventRunnerService.handleEvent("logout", userId as UserId); - await this.stateService.clean(); + await this.stateService.clean({ userId: userId }); + await this.tokenService.clearAccessToken(userId); await this.accountService.clean(userId as UserId); await this.accountService.switchAccount(null); process.env.BW_SESSION = undefined; @@ -918,7 +912,8 @@ export class ServiceContainer { await this.sdkLoadService.loadAndInit(); await this.storageService.init(); - await this.stateService.init(); + + await this.migrationRunner.run(); this.containerService.attachToGlobal(global); await this.i18nService.init(); this.twoFactorService.init(); diff --git a/apps/cli/src/tools/generate.command.ts b/apps/cli/src/tools/generate.command.ts index 64c6118a0c6..1d8a8690ab3 100644 --- a/apps/cli/src/tools/generate.command.ts +++ b/apps/cli/src/tools/generate.command.ts @@ -1,6 +1,9 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { firstValueFrom, of, switchMap } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { DefaultPasswordGenerationOptions, DefaultPassphraseGenerationOptions, @@ -17,7 +20,8 @@ import { CliUtils } from "../utils"; export class GenerateCommand { constructor( private passwordGenerationService: PasswordGenerationServiceAbstraction, - private stateService: StateService, + private tokenService: TokenService, + private accountService: AccountService, ) {} async run(cmdOptions: Record): Promise { @@ -38,7 +42,18 @@ export class GenerateCommand { ambiguous: !normalizedOptions.ambiguous, }; - const enforcedOptions = (await this.stateService.getIsAuthenticated()) + const shouldEnforceOptions = await firstValueFrom( + this.accountService.activeAccount$.pipe( + switchMap((account) => { + if (account == null) { + return of(false); + } + + return this.tokenService.hasAccessToken$(account.id); + }), + ), + ); + const enforcedOptions = shouldEnforceOptions ? (await this.passwordGenerationService.enforcePasswordGeneratorPoliciesOnOptions(options))[0] : options; diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 360d6b4a60e..6bfe71d59f4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/desktop", "description": "A secure and free password manager for all of your devices.", - "version": "2025.8.0", + "version": "2025.8.1", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 544d66bf4f6..9e44f753080 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -40,6 +40,7 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; @@ -176,6 +177,7 @@ export class AppComponent implements OnInit, OnDestroy { private readonly destroyRef: DestroyRef, private readonly documentLangSetter: DocumentLangSetter, private restrictedItemTypesService: RestrictedItemTypesService, + private readonly tokenService: TokenService, ) { this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe(); @@ -685,6 +687,7 @@ export class AppComponent implements OnInit, OnDestroy { await this.stateEventRunnerService.handleEvent("logout", userBeingLoggedOut); await this.stateService.clean({ userId: userBeingLoggedOut }); + await this.tokenService.clearAccessToken(userBeingLoggedOut); await this.accountService.clean(userBeingLoggedOut); // HACK: Wait for the user logging outs authentication status to transition to LoggedOut diff --git a/apps/desktop/src/app/services/init.service.ts b/apps/desktop/src/app/services/init.service.ts index 698427c1e57..2c68821b6c7 100644 --- a/apps/desktop/src/app/services/init.service.ts +++ b/apps/desktop/src/app/services/init.service.ts @@ -15,6 +15,7 @@ import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk- import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; import { NotificationsService } from "@bitwarden/common/platform/notifications"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; +import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service"; import { SyncService as SyncServiceAbstraction } from "@bitwarden/common/platform/sync"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; @@ -52,6 +53,7 @@ export class InitService { private autotypeService: DesktopAutotypeService, private sdkLoadService: SdkLoadService, @Inject(DOCUMENT) private document: Document, + private readonly migrationRunner: MigrationRunner, ) {} init() { @@ -59,7 +61,7 @@ export class InitService { await this.sdkLoadService.loadAndInit(); await this.sshAgentService.init(); this.nativeMessagingService.init(); - await this.stateService.init({ runMigrations: false }); // Desktop will run them in main process + await this.migrationRunner.waitForCompletion(); // Desktop will run migrations in the main process const accounts = await firstValueFrom(this.accountService.accounts$); const setUserKeyInMemoryPromises = []; diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 1c98f3732f0..49e4ab2610a 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -51,6 +51,7 @@ import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/ import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { ClientType } from "@bitwarden/common/enums"; import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service"; +import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypto/services/web-crypto-function.service"; @@ -67,7 +68,6 @@ import { Fido2AuthenticatorService as Fido2AuthenticatorServiceAbstraction } fro import { Fido2UserInterfaceService as Fido2UserInterfaceServiceAbstraction } from "@bitwarden/common/platform/abstractions/fido2/fido2-user-interface.service.abstraction"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service"; import { LogService, LogService as LogServiceAbstraction, @@ -304,7 +304,7 @@ const safeProviders: SafeProvider[] = [ deps: [ PinServiceAbstraction, InternalMasterPasswordServiceAbstraction, - KeyGenerationServiceAbstraction, + KeyGenerationService, CryptoFunctionServiceAbstraction, EncryptService, PlatformUtilsServiceAbstraction, diff --git a/apps/desktop/src/key-management/electron-key.service.spec.ts b/apps/desktop/src/key-management/electron-key.service.spec.ts index af01bf51c15..2d60c47217d 100644 --- a/apps/desktop/src/key-management/electron-key.service.spec.ts +++ b/apps/desktop/src/key-management/electron-key.service.spec.ts @@ -1,10 +1,10 @@ import { mock } from "jest-mock-extended"; +import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; -import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; 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"; diff --git a/apps/desktop/src/key-management/electron-key.service.ts b/apps/desktop/src/key-management/electron-key.service.ts index 48ccd3f27fd..59295b2ca21 100644 --- a/apps/desktop/src/key-management/electron-key.service.ts +++ b/apps/desktop/src/key-management/electron-key.service.ts @@ -1,9 +1,9 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; -import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; 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"; @@ -65,7 +65,7 @@ export class ElectronKeyService extends DefaultKeyService { protected override async getKeyFromStorage( keySuffix: KeySuffixOptions, - userId?: UserId, + userId: UserId, ): Promise { return await super.getKeyFromStorage(keySuffix, userId); } diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 7f9f2737e65..c076ff244dc 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -4152,5 +4152,9 @@ }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." + }, + "moreBreadcrumbs": { + "message": "More breadcrumbs", + "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." } } diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json index 44ee6ec862d..6daff35e115 100644 --- a/apps/desktop/src/package-lock.json +++ b/apps/desktop/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitwarden/desktop", - "version": "2025.8.0", + "version": "2025.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitwarden/desktop", - "version": "2025.8.0", + "version": "2025.8.1", "license": "GPL-3.0", "dependencies": { "@bitwarden/desktop-napi": "file:../desktop_native/napi" diff --git a/apps/desktop/src/package.json b/apps/desktop/src/package.json index ffe70bb7bd7..ea2e8affda2 100644 --- a/apps/desktop/src/package.json +++ b/apps/desktop/src/package.json @@ -2,7 +2,7 @@ "name": "@bitwarden/desktop", "productName": "Bitwarden", "description": "A secure and free password manager for all of your devices.", - "version": "2025.8.0", + "version": "2025.8.1", "author": "Bitwarden Inc. (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", diff --git a/apps/web/src/404.html b/apps/web/src/404.html index 1a01aee40c7..75e4641a0cc 100644 --- a/apps/web/src/404.html +++ b/apps/web/src/404.html @@ -16,16 +16,18 @@ - Bitwarden + Bitwarden -

+

Sorry, this page isn't available.

The link you followed may be broken, or the page may have been removed. Try going back to the previous page or see our - Help Center for - more information. + Help Center + for more information.

Go to your web vault diff --git a/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.html b/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.html index 50d34227b56..9c3e607d6eb 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.html +++ b/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.html @@ -34,6 +34,7 @@ [bitMenuTriggerFor]="editCollectionMenu" size="small" type="button" + [label]="'editCollection' | i18n" > diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts index 5d2460abdc1..87f309c6f66 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts @@ -79,8 +79,11 @@ import { DecryptionFailureDialogComponent, PasswordRepromptService, } from "@bitwarden/vault"; -import { OrganizationResellerRenewalWarningComponent } from "@bitwarden/web-vault/app/billing/warnings/components/organization-reseller-renewal-warning.component"; -import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/warnings/services/organization-warnings.service"; +import { + OrganizationFreeTrialWarningComponent, + OrganizationResellerRenewalWarningComponent, +} from "@bitwarden/web-vault/app/billing/organizations/warnings/components"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; import { VaultItemsComponent } from "@bitwarden/web-vault/app/vault/components/vault-items/vault-items.component"; import { BillingNotificationService } from "../../../billing/services/billing-notification.service"; @@ -90,7 +93,6 @@ import { } from "../../../billing/services/reseller-warning.service"; import { TrialFlowService } from "../../../billing/services/trial-flow.service"; import { FreeTrial } from "../../../billing/types/free-trial"; -import { OrganizationFreeTrialWarningComponent } from "../../../billing/warnings/components/organization-free-trial-warning.component"; import { SharedModule } from "../../../shared"; import { AssignCollectionsWebComponent } from "../../../vault/components/assign-collections"; import { @@ -674,6 +676,15 @@ export class VaultComponent implements OnInit, OnDestroy { ) .subscribe(); + organization$ + .pipe( + switchMap((organization) => + this.organizationWarningsService.showSubscribeBeforeFreeTrialEndsDialog$(organization), + ), + takeUntil(this.destroy$), + ) + .subscribe(); + const freeTrial$ = combineLatest([ organization$, this.hasSubscription$.pipe(filter((hasSubscription) => hasSubscription !== null)), diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html index cbb4e1cf064..be9a85ffe4b 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html @@ -150,6 +150,12 @@ > {{ "accessingUsingProvider" | i18n: organization.providerName }} + + diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts index 89f62ed8975..4b6e9a431b4 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts @@ -28,6 +28,11 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { getById } from "@bitwarden/common/platform/misc"; import { BannerModule, IconModule, AdminConsoleLogo } from "@bitwarden/components"; +import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; +import { NonIndividualSubscriber } from "@bitwarden/web-vault/app/billing/types"; +import { TaxIdWarningComponent } from "@bitwarden/web-vault/app/billing/warnings/components"; +import { TaxIdWarningType } from "@bitwarden/web-vault/app/billing/warnings/types"; import { FreeFamiliesPolicyService } from "../../../billing/services/free-families-policy.service"; import { OrgSwitcherComponent } from "../../../layouts/org-switcher/org-switcher.component"; @@ -44,6 +49,9 @@ import { WebLayoutModule } from "../../../layouts/web-layout.module"; IconModule, OrgSwitcherComponent, BannerModule, + TaxIdWarningComponent, + TaxIdWarningComponent, + OrganizationWarningsModule, ], }) export class OrganizationLayoutComponent implements OnInit { @@ -58,7 +66,6 @@ export class OrganizationLayoutComponent implements OnInit { showPaymentAndHistory$: Observable; hideNewOrgButton$: Observable; organizationIsUnmanaged$: Observable; - enterpriseOrganization$: Observable; protected isBreadcrumbEventLogsEnabled$: Observable; protected showSponsoredFamiliesDropdown$: Observable; @@ -69,6 +76,9 @@ export class OrganizationLayoutComponent implements OnInit { textKey: string; }>; + protected subscriber$: Observable; + protected getTaxIdWarning$: () => Observable; + constructor( private route: ActivatedRoute, private organizationService: OrganizationService, @@ -79,6 +89,7 @@ export class OrganizationLayoutComponent implements OnInit { private accountService: AccountService, private freeFamiliesPolicyService: FreeFamiliesPolicyService, private organizationBillingService: OrganizationBillingServiceAbstraction, + private organizationWarningsService: OrganizationWarningsService, ) {} async ngOnInit() { @@ -150,6 +161,20 @@ export class OrganizationLayoutComponent implements OnInit { : { route: "billing/payment-method", textKey: "paymentMethod" }, ), ); + + this.subscriber$ = this.organization$.pipe( + map((organization) => ({ + type: "organization", + data: organization, + })), + ); + + this.getTaxIdWarning$ = () => + this.organization$.pipe( + switchMap((organization) => + this.organizationWarningsService.getTaxIdWarning$(organization), + ), + ); } canShowVaultTab(organization: Organization): boolean { @@ -179,4 +204,6 @@ export class OrganizationLayoutComponent implements OnInit { getReportTabLabel(organization: Organization): string { return organization.useEvents ? "reporting" : "reports"; } + + refreshTaxIdWarning = () => this.organizationWarningsService.refreshTaxIdWarning(); } diff --git a/apps/web/src/app/admin-console/organizations/manage/events.component.ts b/apps/web/src/app/admin-console/organizations/manage/events.component.ts index 3daa6c17d07..07f6be7d7f6 100644 --- a/apps/web/src/app/admin-console/organizations/manage/events.component.ts +++ b/apps/web/src/app/admin-console/organizations/manage/events.component.ts @@ -34,6 +34,8 @@ import { openChangePlanDialog, } from "../../../billing/organizations/change-plan-dialog.component"; import { EventService } from "../../../core"; +import { HeaderModule } from "../../../layouts/header/header.module"; +import { SharedModule } from "../../../shared"; import { EventExportService } from "../../../tools/event-export"; import { BaseEventsComponent } from "../../common/base.events.component"; @@ -46,9 +48,8 @@ const EVENT_SYSTEM_USER_TO_TRANSLATION: Record = { }; @Component({ - selector: "app-org-events", templateUrl: "events.component.html", - standalone: false, + imports: [SharedModule, HeaderModule], }) export class EventsComponent extends BaseEventsComponent implements OnInit, OnDestroy { exportFileName = "org-events"; diff --git a/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.html b/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.html index 101512dea04..cc90d10fb4a 100644 --- a/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.html +++ b/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.html @@ -80,7 +80,7 @@ bitIconButton="bwi-trash" bitFormButton [bitAction]="delete" - [appA11yTitle]="'delete' | i18n" + [label]="'delete' | i18n" > diff --git a/apps/web/src/app/admin-console/organizations/manage/groups.component.html b/apps/web/src/app/admin-console/organizations/manage/groups.component.html index 4518513ba7d..62d0b5b874b 100644 --- a/apps/web/src/app/admin-console/organizations/manage/groups.component.html +++ b/apps/web/src/app/admin-console/organizations/manage/groups.component.html @@ -46,7 +46,7 @@ type="button" bitIconButton="bwi-ellipsis-v" size="small" - appA11yTitle="{{ 'options' | i18n }}" + label="{{ 'options' | i18n }}" > @@ -82,7 +82,7 @@ type="button" bitIconButton="bwi-ellipsis-v" size="small" - appA11yTitle="{{ 'options' | i18n }}" + label="{{ 'options' | i18n }}" > diff --git a/apps/web/src/app/admin-console/organizations/manage/user-confirm.component.ts b/apps/web/src/app/admin-console/organizations/manage/user-confirm.component.ts index 03b77cfaa71..16543cdb58c 100644 --- a/apps/web/src/app/admin-console/organizations/manage/user-confirm.component.ts +++ b/apps/web/src/app/admin-console/organizations/manage/user-confirm.component.ts @@ -8,6 +8,8 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; +import { SharedModule } from "../../../shared"; + export type UserConfirmDialogData = { name: string; userId: string; @@ -16,9 +18,8 @@ export type UserConfirmDialogData = { }; @Component({ - selector: "app-user-confirm", templateUrl: "user-confirm.component.html", - standalone: false, + imports: [SharedModule], }) export class UserConfirmComponent implements OnInit { name: string; diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.html b/apps/web/src/app/admin-console/organizations/members/members.component.html index 49946806efc..7e0aa465bf3 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.html +++ b/apps/web/src/app/admin-console/organizations/members/members.component.html @@ -106,7 +106,7 @@ type="button" bitIconButton="bwi-ellipsis-v" size="small" - appA11yTitle="{{ 'options' | i18n }}" + label="{{ 'options' | i18n }}" *ngIf="showUserManagementControls$ | async" > @@ -350,7 +350,7 @@ type="button" bitIconButton="bwi-ellipsis-v" size="small" - appA11yTitle="{{ 'options' | i18n }}" + label="{{ 'options' | i18n }}" > diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts index b4542be8d26..dedf13720bf 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts @@ -10,10 +10,10 @@ import { from, lastValueFrom, map, + merge, Observable, shareReplay, switchMap, - tap, } from "rxjs"; import { @@ -57,12 +57,12 @@ import { OrganizationId } from "@bitwarden/common/types/guid"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; import { ChangePlanDialogResultType, openChangePlanDialog, } from "../../../billing/organizations/change-plan-dialog.component"; -import { OrganizationWarningsService } from "../../../billing/warnings/services"; import { BaseMembersComponent } from "../../common/base-members.component"; import { PeopleTableDataSource } from "../../common/people-table-data-source"; import { GroupApiService } from "../core"; @@ -253,11 +253,16 @@ export class MembersComponent extends BaseMembersComponent this.showUserManagementControls$ = organization$.pipe( map((organization) => organization.canManageUsers), ); + organization$ .pipe( + switchMap((organization) => + merge( + this.organizationWarningsService.showInactiveSubscriptionDialog$(organization), + this.organizationWarningsService.showSubscribeBeforeFreeTrialEndsDialog$(organization), + ), + ), takeUntilDestroyed(), - tap((org) => (this.organization = org)), - switchMap((org) => this.organizationWarningsService.showInactiveSubscriptionDialog$(org)), ) .subscribe(); } diff --git a/apps/web/src/app/admin-console/organizations/members/members.module.ts b/apps/web/src/app/admin-console/organizations/members/members.module.ts index d9c5ae356a2..efc091cb335 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.module.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.module.ts @@ -4,8 +4,8 @@ import { NgModule } from "@angular/core"; import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-strength/password-strength-v2.component"; import { PasswordCalloutComponent } from "@bitwarden/auth/angular"; import { ScrollLayoutDirective } from "@bitwarden/components"; +import { OrganizationFreeTrialWarningComponent } from "@bitwarden/web-vault/app/billing/organizations/warnings/components"; -import { OrganizationFreeTrialWarningComponent } from "../../../billing/warnings/components"; import { LooseComponentsModule } from "../../../shared"; import { SharedOrganizationModule } from "../shared"; diff --git a/apps/web/src/app/admin-console/organizations/organization.module.ts b/apps/web/src/app/admin-console/organizations/organization.module.ts index 687361760c9..d956174149b 100644 --- a/apps/web/src/app/admin-console/organizations/organization.module.ts +++ b/apps/web/src/app/admin-console/organizations/organization.module.ts @@ -2,6 +2,7 @@ import { ScrollingModule } from "@angular/cdk/scrolling"; import { NgModule } from "@angular/core"; import { ScrollLayoutDirective } from "@bitwarden/components"; +import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module"; import { LooseComponentsModule } from "../../shared"; @@ -21,6 +22,7 @@ import { AccessSelectorModule } from "./shared/components/access-selector"; LooseComponentsModule, ScrollingModule, ScrollLayoutDirective, + OrganizationWarningsModule, ], declarations: [GroupsComponent, GroupAddEditComponent], }) diff --git a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector-dialog.stories.ts b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector-dialog.stories.ts index a803f6ef7b5..5cb61197b99 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector-dialog.stories.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector-dialog.stories.ts @@ -51,7 +51,7 @@ const render: Story["render"] = (args) => ({ buttonType="danger" size="default" title="Delete" - aria-label="Delete"> + label="Delete"> `, diff --git a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.html b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.html index e0ffc9a4bce..116af15f579 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.html +++ b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.html @@ -122,7 +122,7 @@ type="button" bitIconButton="bwi-close" buttonType="muted" - appA11yTitle="{{ 'remove' | i18n }} {{ item.labelName }}" + label="{{ 'remove' | i18n }} {{ item.labelName }}" [disabled]="disabled" (click)="selectionList.deselectItem(item.id); handleBlur()" > diff --git a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.html b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.html index 4a91fcc2a41..dec257b3741 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.html +++ b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.html @@ -143,7 +143,7 @@ buttonType="danger" class="tw-ml-auto" bitFormButton - [appA11yTitle]="'delete' | i18n" + [label]="'delete' | i18n" [bitAction]="delete" [disabled]="loading" > diff --git a/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.ts b/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.ts index b0c89cd30ab..2ca566a0af2 100644 --- a/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.ts +++ b/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.ts @@ -1,12 +1,14 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { CommonModule } from "@angular/common"; import { Component, inject } from "@angular/core"; import { Params } from "@angular/router"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { OrganizationSponsorshipResponse } from "@bitwarden/common/admin-console/models/response/organization-sponsorship.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { Icons, ToastService } from "@bitwarden/components"; +import { IconModule, Icons, ToastService } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; import { BaseAcceptComponent } from "../../../common/base.accept.component"; @@ -16,9 +18,8 @@ import { BaseAcceptComponent } from "../../../common/base.accept.component"; * personal email address." - https://bitwarden.com/learning/free-families-plan-for-enterprise/ */ @Component({ - selector: "app-accept-family-sponsorship", templateUrl: "accept-family-sponsorship.component.html", - standalone: false, + imports: [CommonModule, I18nPipe, IconModule], }) export class AcceptFamilySponsorshipComponent extends BaseAcceptComponent { protected logo = Icons.BitwardenLogo; diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 694d0c6eb9a..ae20670c2dd 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -12,6 +12,7 @@ import { EventUploadService } from "@bitwarden/common/abstractions/event/event-u import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service"; @@ -89,6 +90,7 @@ export class AppComponent implements OnDestroy, OnInit { private deviceTrustToastService: DeviceTrustToastService, private readonly destoryRef: DestroyRef, private readonly documentLangSetter: DocumentLangSetter, + private readonly tokenService: TokenService, ) { this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe(); @@ -297,6 +299,7 @@ export class AppComponent implements OnDestroy, OnInit { await this.searchService.clearIndex(userId); this.authService.logOut(async () => { await this.stateService.clean({ userId: userId }); + await this.tokenService.clearAccessToken(userId); await this.accountService.clean(userId); await this.accountService.switchAccount(null); diff --git a/apps/web/src/app/auth/settings/emergency-access/emergency-access-add-edit.component.html b/apps/web/src/app/auth/settings/emergency-access/emergency-access-add-edit.component.html index 6e87d66d18b..1c04c03a8d2 100644 --- a/apps/web/src/app/auth/settings/emergency-access/emergency-access-add-edit.component.html +++ b/apps/web/src/app/auth/settings/emergency-access/emergency-access-add-edit.component.html @@ -62,7 +62,7 @@ buttonType="danger" [bitAction]="delete" *ngIf="editMode" - appA11yTitle="{{ 'delete' | i18n }}" + label="{{ 'delete' | i18n }}" > diff --git a/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.html b/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.html index 8a802e4f6af..70165a94fc3 100644 --- a/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.html +++ b/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.html @@ -89,7 +89,7 @@ @@ -212,7 +212,7 @@ diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.html b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.html index 1df1e52f7e4..dbad422a32e 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.html +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.html @@ -39,7 +39,7 @@ type="button" buttonType="danger" (click)="remove(i)" - appA11yTitle="{{ 'remove' | i18n }}" + label="{{ 'remove' | i18n }}" > diff --git a/apps/web/src/app/auth/verify-email-token.component.ts b/apps/web/src/app/auth/verify-email-token.component.ts index 9e44cc7a713..2c4fa7f447c 100644 --- a/apps/web/src/app/auth/verify-email-token.component.ts +++ b/apps/web/src/app/auth/verify-email-token.component.ts @@ -2,14 +2,15 @@ // @ts-strict-ignore import { Component, OnInit } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; import { first } from "rxjs/operators"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { VerifyEmailRequest } from "@bitwarden/common/models/request/verify-email.request"; 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 { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { ToastService } from "@bitwarden/components"; @Component({ @@ -25,7 +26,7 @@ export class VerifyEmailTokenComponent implements OnInit { private route: ActivatedRoute, private apiService: ApiService, private logService: LogService, - private stateService: StateService, + private tokenService: TokenService, private toastService: ToastService, ) {} @@ -37,7 +38,7 @@ export class VerifyEmailTokenComponent implements OnInit { await this.apiService.postAccountVerifyEmailToken( new VerifyEmailRequest(qParams.userId, qParams.token), ); - if (await this.stateService.getIsAuthenticated()) { + if (await firstValueFrom(this.tokenService.hasAccessToken$(qParams.userId))) { await this.apiService.refreshIdentityToken(); } this.toastService.showToast({ diff --git a/apps/web/src/app/billing/clients/index.ts b/apps/web/src/app/billing/clients/index.ts new file mode 100644 index 00000000000..ff962abcbf3 --- /dev/null +++ b/apps/web/src/app/billing/clients/index.ts @@ -0,0 +1,2 @@ +export * from "./organization-billing.client"; +export * from "./subscriber-billing.client"; diff --git a/apps/web/src/app/billing/clients/organization-billing.client.ts b/apps/web/src/app/billing/clients/organization-billing.client.ts new file mode 100644 index 00000000000..a8b3b31a4a4 --- /dev/null +++ b/apps/web/src/app/billing/clients/organization-billing.client.ts @@ -0,0 +1,22 @@ +import { Injectable } from "@angular/core"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { OrganizationWarningsResponse } from "@bitwarden/web-vault/app/billing/organizations/warnings/types"; + +@Injectable() +export class OrganizationBillingClient { + constructor(private apiService: ApiService) {} + + getWarnings = async (organizationId: OrganizationId): Promise => { + const response = await this.apiService.send( + "GET", + `/organizations/${organizationId}/billing/vnext/warnings`, + null, + true, + true, + ); + + return new OrganizationWarningsResponse(response); + }; +} diff --git a/apps/web/src/app/billing/services/billing.client.ts b/apps/web/src/app/billing/clients/subscriber-billing.client.ts similarity index 72% rename from apps/web/src/app/billing/services/billing.client.ts rename to apps/web/src/app/billing/clients/subscriber-billing.client.ts index 69f82eab19a..18ca215ef0c 100644 --- a/apps/web/src/app/billing/services/billing.client.ts +++ b/apps/web/src/app/billing/clients/subscriber-billing.client.ts @@ -10,7 +10,7 @@ import { MaskedPaymentMethodResponse, TokenizedPaymentMethod, } from "../payment/types"; -import { BillableEntity } from "../types"; +import { BitwardenSubscriber } from "../types"; type Result = | { @@ -23,28 +23,28 @@ type Result = }; @Injectable() -export class BillingClient { +export class SubscriberBillingClient { constructor(private apiService: ApiService) {} - private getEndpoint = (entity: BillableEntity): string => { - switch (entity.type) { + private getEndpoint = (subscriber: BitwardenSubscriber): string => { + switch (subscriber.type) { case "account": { return "/account/billing/vnext"; } case "organization": { - return `/organizations/${entity.data.id}/billing/vnext`; + return `/organizations/${subscriber.data.id}/billing/vnext`; } case "provider": { - return `/providers/${entity.data.id}/billing/vnext`; + return `/providers/${subscriber.data.id}/billing/vnext`; } } }; addCreditWithBitPay = async ( - owner: BillableEntity, + subscriber: BitwardenSubscriber, credit: { amount: number; redirectUrl: string }, ): Promise> => { - const path = `${this.getEndpoint(owner)}/credit/bitpay`; + const path = `${this.getEndpoint(subscriber)}/credit/bitpay`; try { const data = await this.apiService.send("POST", path, credit, true, true); return { @@ -62,29 +62,31 @@ export class BillingClient { } }; - getBillingAddress = async (owner: BillableEntity): Promise => { - const path = `${this.getEndpoint(owner)}/address`; + getBillingAddress = async (subscriber: BitwardenSubscriber): Promise => { + const path = `${this.getEndpoint(subscriber)}/address`; const data = await this.apiService.send("GET", path, null, true, true); return data ? new BillingAddressResponse(data) : null; }; - getCredit = async (owner: BillableEntity): Promise => { - const path = `${this.getEndpoint(owner)}/credit`; + getCredit = async (subscriber: BitwardenSubscriber): Promise => { + const path = `${this.getEndpoint(subscriber)}/credit`; const data = await this.apiService.send("GET", path, null, true, true); return data ? (data as number) : null; }; - getPaymentMethod = async (owner: BillableEntity): Promise => { - const path = `${this.getEndpoint(owner)}/payment-method`; + getPaymentMethod = async ( + subscriber: BitwardenSubscriber, + ): Promise => { + const path = `${this.getEndpoint(subscriber)}/payment-method`; const data = await this.apiService.send("GET", path, null, true, true); return data ? new MaskedPaymentMethodResponse(data).value : null; }; updateBillingAddress = async ( - owner: BillableEntity, + subscriber: BitwardenSubscriber, billingAddress: BillingAddress, ): Promise> => { - const path = `${this.getEndpoint(owner)}/address`; + const path = `${this.getEndpoint(subscriber)}/address`; try { const data = await this.apiService.send("PUT", path, billingAddress, true, true); return { @@ -103,11 +105,11 @@ export class BillingClient { }; updatePaymentMethod = async ( - owner: BillableEntity, + subscriber: BitwardenSubscriber, paymentMethod: TokenizedPaymentMethod, billingAddress: Pick | null, ): Promise> => { - const path = `${this.getEndpoint(owner)}/payment-method`; + const path = `${this.getEndpoint(subscriber)}/payment-method`; try { const request = { ...paymentMethod, @@ -130,10 +132,10 @@ export class BillingClient { }; verifyBankAccount = async ( - owner: BillableEntity, + subscriber: BitwardenSubscriber, descriptorCode: string, ): Promise> => { - const path = `${this.getEndpoint(owner)}/payment-method/verify-bank-account`; + const path = `${this.getEndpoint(subscriber)}/payment-method/verify-bank-account`; try { const data = await this.apiService.send("POST", path, { descriptorCode }, true, true); return { diff --git a/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.html b/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.html index c10590d8b1b..5bb47cd8a2e 100644 --- a/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.html +++ b/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.html @@ -12,13 +12,13 @@ } @else { diff --git a/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts b/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts index 4a4d0f60c0b..9f46d9d3909 100644 --- a/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts +++ b/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts @@ -20,13 +20,13 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { HeaderModule } from "../../../layouts/header/header.module"; import { SharedModule } from "../../../shared"; +import { SubscriberBillingClient } from "../../clients"; import { DisplayAccountCreditComponent, DisplayPaymentMethodComponent, } from "../../payment/components"; import { MaskedPaymentMethod } from "../../payment/types"; -import { BillingClient } from "../../services"; -import { accountToBillableEntity, BillableEntity } from "../../types"; +import { mapAccountToSubscriber, BitwardenSubscriber } from "../../types"; class RedirectError { constructor( @@ -36,7 +36,7 @@ class RedirectError { } type View = { - account: BillableEntity; + account: BitwardenSubscriber; paymentMethod: MaskedPaymentMethod | null; credit: number | null; }; @@ -50,7 +50,7 @@ type View = { HeaderModule, SharedModule, ], - providers: [BillingClient], + providers: [SubscriberBillingClient], }) export class AccountPaymentDetailsComponent { private viewState$ = new BehaviorSubject(null); @@ -68,7 +68,7 @@ export class AccountPaymentDetailsComponent { }), ), ), - accountToBillableEntity, + mapAccountToSubscriber, switchMap(async (account) => { const [paymentMethod, credit] = await Promise.all([ this.billingClient.getPaymentMethod(account), @@ -100,7 +100,7 @@ export class AccountPaymentDetailsComponent { constructor( private accountService: AccountService, private activatedRoute: ActivatedRoute, - private billingClient: BillingClient, + private billingClient: SubscriberBillingClient, private configService: ConfigService, private router: Router, ) {} diff --git a/apps/web/src/app/billing/members/free-bitwarden-families.component.html b/apps/web/src/app/billing/members/free-bitwarden-families.component.html index ddf7c506745..697a5963a71 100644 --- a/apps/web/src/app/billing/members/free-bitwarden-families.component.html +++ b/apps/web/src/app/billing/members/free-bitwarden-families.component.html @@ -53,7 +53,7 @@ bitIconButton="bwi-ellipsis-v" buttonType="main" [bitMenuTriggerFor]="appListDropdown" - appA11yTitle="{{ 'options' | i18n }}" + label="{{ 'options' | i18n }}" > @if (!isSelfHosted && !sponsoredFamily.validUntil) { diff --git a/apps/web/src/app/billing/organizations/billing-sync-api-key.component.html b/apps/web/src/app/billing/organizations/billing-sync-api-key.component.html index a09000ef55f..465a50ec8c3 100644 --- a/apps/web/src/app/billing/organizations/billing-sync-api-key.component.html +++ b/apps/web/src/app/billing/organizations/billing-sync-api-key.component.html @@ -33,7 +33,7 @@ showToast [valueLabel]="'billingSyncKey' | i18n" [appCopyClick]="clientSecret" - [appA11yTitle]="'copyValue' | i18n" + [label]="'copyValue' | i18n" >
diff --git a/apps/web/src/app/billing/organizations/billing-sync-key.component.html b/apps/web/src/app/billing/organizations/billing-sync-key.component.html index 9736351deca..94a81140344 100644 --- a/apps/web/src/app/billing/organizations/billing-sync-key.component.html +++ b/apps/web/src/app/billing/organizations/billing-sync-key.component.html @@ -33,7 +33,7 @@ bitIconButton="bwi-trash" bitFormButton [bitAction]="deleteConnection" - appA11yTitle="{{ 'delete' | i18n }}" + label="{{ 'delete' | i18n }}" > 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 ace3d749a3f..f899b8eccb4 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 @@ -359,7 +359,7 @@ type="button" [bitIconButton]="totalOpened ? 'bwi-angle-down' : 'bwi-angle-up'" size="small" - aria-hidden="true" + [label]="totalOpened ? ('hidePricingSummary' | i18n) : ('showPricingSummary' | i18n)" >

diff --git a/apps/web/src/app/billing/organizations/change-plan.component.html b/apps/web/src/app/billing/organizations/change-plan.component.html index 75a12122d19..1cd15a7c836 100644 --- a/apps/web/src/app/billing/organizations/change-plan.component.html +++ b/apps/web/src/app/billing/organizations/change-plan.component.html @@ -8,7 +8,7 @@ type="button" size="small" class="tw-float-right" - appA11yTitle="{{ 'cancel' | i18n }}" + label="{{ 'cancel' | i18n }}" (click)="cancel()" >

{{ "changeBillingPlan" | i18n }}

diff --git a/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.html b/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.html index 17f4349fdd5..cd31f1f33be 100644 --- a/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.html +++ b/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.html @@ -21,19 +21,20 @@ } @else { diff --git a/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.ts b/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.ts index e357444b943..d1dfea40fe2 100644 --- a/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.ts +++ b/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.ts @@ -1,8 +1,9 @@ -import { Component, OnInit, ViewChild } from "@angular/core"; +import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { BehaviorSubject, catchError, + combineLatest, EMPTY, filter, firstValueFrom, @@ -11,8 +12,12 @@ import { map, merge, Observable, + of, shareReplay, + Subject, switchMap, + take, + takeUntil, tap, } from "rxjs"; @@ -26,19 +31,26 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { DialogService } from "@bitwarden/components"; - -import { HeaderModule } from "../../../layouts/header/header.module"; -import { SharedModule } from "../../../shared"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; +import { OrganizationFreeTrialWarningComponent } from "@bitwarden/web-vault/app/billing/organizations/warnings/components"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; import { ChangePaymentMethodDialogComponent, DisplayAccountCreditComponent, DisplayBillingAddressComponent, DisplayPaymentMethodComponent, -} from "../../payment/components"; -import { BillingAddress, MaskedPaymentMethod } from "../../payment/types"; -import { BillingClient } from "../../services"; -import { BillableEntity, organizationToBillableEntity } from "../../types"; -import { OrganizationFreeTrialWarningComponent } from "../../warnings/components"; +} from "@bitwarden/web-vault/app/billing/payment/components"; +import { + BillingAddress, + MaskedPaymentMethod, +} from "@bitwarden/web-vault/app/billing/payment/types"; +import { + BitwardenSubscriber, + mapOrganizationToSubscriber, +} from "@bitwarden/web-vault/app/billing/types"; +import { TaxIdWarningType } from "@bitwarden/web-vault/app/billing/warnings/types"; +import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; class RedirectError { constructor( @@ -48,93 +60,100 @@ class RedirectError { } type View = { - organization: BillableEntity; + organization: BitwardenSubscriber; paymentMethod: MaskedPaymentMethod | null; billingAddress: BillingAddress | null; credit: number | null; + taxIdWarning: TaxIdWarningType | null; }; @Component({ templateUrl: "./organization-payment-details.component.html", standalone: true, imports: [ - DisplayBillingAddressComponent, DisplayAccountCreditComponent, + DisplayBillingAddressComponent, DisplayPaymentMethodComponent, HeaderModule, OrganizationFreeTrialWarningComponent, SharedModule, ], - providers: [BillingClient], }) -export class OrganizationPaymentDetailsComponent implements OnInit { - @ViewChild(OrganizationFreeTrialWarningComponent) - organizationFreeTrialWarningComponent!: OrganizationFreeTrialWarningComponent; - +export class OrganizationPaymentDetailsComponent implements OnInit, OnDestroy { private viewState$ = new BehaviorSubject(null); - private load$: Observable = this.accountService.activeAccount$ - .pipe( - getUserId, - switchMap((userId) => - this.organizationService - .organizations$(userId) - .pipe(getOrganizationById(this.activatedRoute.snapshot.params.organizationId)), - ), - ) - .pipe( - switchMap((organization) => - this.configService - .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) - .pipe( - map((managePaymentDetailsOutsideCheckout) => { - if (!managePaymentDetailsOutsideCheckout) { - throw new RedirectError(["../payment-method"], this.activatedRoute); - } - return organization; - }), - ), - ), - organizationToBillableEntity, - switchMap(async (organization) => { - const [paymentMethod, billingAddress, credit] = await Promise.all([ - this.billingClient.getPaymentMethod(organization), - this.billingClient.getBillingAddress(organization), - this.billingClient.getCredit(organization), - ]); + protected organization$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.organizationService + .organizations$(userId) + .pipe(getOrganizationById(this.activatedRoute.snapshot.params.organizationId)), + ), + filter((organization): organization is Organization => !!organization), + ); - return { - organization, - paymentMethod, - billingAddress, - credit, - }; - }), - catchError((error: unknown) => { - if (error instanceof RedirectError) { - return from(this.router.navigate(error.path, { relativeTo: error.relativeTo })).pipe( - switchMap(() => EMPTY), - ); - } - throw error; - }), - ); + private load$: Observable = this.organization$.pipe( + switchMap((organization) => + this.configService + .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) + .pipe( + map((managePaymentDetailsOutsideCheckout) => { + if (!managePaymentDetailsOutsideCheckout) { + throw new RedirectError(["../payment-method"], this.activatedRoute); + } + return organization; + }), + ), + ), + mapOrganizationToSubscriber, + switchMap(async (organization) => { + const getTaxIdWarning = firstValueFrom( + this.organizationWarningsService.getTaxIdWarning$(organization.data as Organization), + ); + + const [paymentMethod, billingAddress, credit, taxIdWarning] = await Promise.all([ + this.subscriberBillingClient.getPaymentMethod(organization), + this.subscriberBillingClient.getBillingAddress(organization), + this.subscriberBillingClient.getCredit(organization), + getTaxIdWarning, + ]); + + return { + organization, + paymentMethod, + billingAddress, + credit, + taxIdWarning, + }; + }), + catchError((error: unknown) => { + if (error instanceof RedirectError) { + return from(this.router.navigate(error.path, { relativeTo: error.relativeTo })).pipe( + switchMap(() => EMPTY), + ); + } + throw error; + }), + ); view$: Observable = merge( this.load$.pipe(tap((view) => this.viewState$.next(view))), this.viewState$.pipe(filter((view): view is View => view !== null)), ).pipe(shareReplay({ bufferSize: 1, refCount: true })); - organization$ = this.view$.pipe(map((view) => view.organization.data as Organization)); + private destroy$ = new Subject(); + + protected enableTaxIdWarning!: boolean; constructor( private accountService: AccountService, private activatedRoute: ActivatedRoute, - private billingClient: BillingClient, private configService: ConfigService, private dialogService: DialogService, private organizationService: OrganizationService, + private organizationWarningsService: OrganizationWarningsService, private router: Router, + private subscriberBillingClient: SubscriberBillingClient, ) {} async ngOnInit() { @@ -145,24 +164,66 @@ export class OrganizationPaymentDetailsComponent implements OnInit { history.replaceState({ ...history.state, launchPaymentModalAutomatically: false }, ""); await this.changePaymentMethod(); } + + this.enableTaxIdWarning = await this.configService.getFeatureFlag( + FeatureFlag.PM22415_TaxIDWarnings, + ); + + if (this.enableTaxIdWarning) { + this.organizationWarningsService.taxIdWarningRefreshed$ + .pipe( + switchMap((warning) => + combineLatest([ + of(warning), + this.organization$.pipe(take(1)).pipe( + mapOrganizationToSubscriber, + switchMap((organization) => + this.subscriberBillingClient.getBillingAddress(organization), + ), + ), + ]), + ), + takeUntil(this.destroy$), + ) + .subscribe(([taxIdWarning, billingAddress]) => { + if (this.viewState$.value) { + this.viewState$.next({ + ...this.viewState$.value, + taxIdWarning, + billingAddress, + }); + } + }); + } + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); } changePaymentMethod = async () => { const view = await firstValueFrom(this.view$); const dialogRef = ChangePaymentMethodDialogComponent.open(this.dialogService, { data: { - owner: view.organization, + subscriber: view.organization, }, }); const result = await lastValueFrom(dialogRef.closed); if (result?.type === "success") { await this.setPaymentMethod(result.paymentMethod); - this.organizationFreeTrialWarningComponent.refresh(); + this.organizationWarningsService.refreshFreeTrialWarning(); } }; setBillingAddress = (billingAddress: BillingAddress) => { if (this.viewState$.value) { + if ( + this.enableTaxIdWarning && + this.viewState$.value.billingAddress?.taxId !== billingAddress.taxId + ) { + this.organizationWarningsService.refreshTaxIdWarning(); + } this.viewState$.next({ ...this.viewState$.value, billingAddress, @@ -174,7 +235,7 @@ export class OrganizationPaymentDetailsComponent implements OnInit { if (this.viewState$.value) { const billingAddress = this.viewState$.value.billingAddress ?? - (await this.billingClient.getBillingAddress(this.viewState$.value.organization)); + (await this.subscriberBillingClient.getBillingAddress(this.viewState$.value.organization)); this.viewState$.next({ ...this.viewState$.value, diff --git a/apps/web/src/app/billing/organizations/warnings/components/index.ts b/apps/web/src/app/billing/organizations/warnings/components/index.ts new file mode 100644 index 00000000000..1e1e0682e62 --- /dev/null +++ b/apps/web/src/app/billing/organizations/warnings/components/index.ts @@ -0,0 +1,2 @@ +export * from "./organization-free-trial-warning.component"; +export * from "./organization-reseller-renewal-warning.component"; diff --git a/apps/web/src/app/billing/warnings/components/organization-free-trial-warning.component.ts b/apps/web/src/app/billing/organizations/warnings/components/organization-free-trial-warning.component.ts similarity index 54% rename from apps/web/src/app/billing/warnings/components/organization-free-trial-warning.component.ts rename to apps/web/src/app/billing/organizations/warnings/components/organization-free-trial-warning.component.ts index a7ce53c9998..4925e4bc01d 100644 --- a/apps/web/src/app/billing/warnings/components/organization-free-trial-warning.component.ts +++ b/apps/web/src/app/billing/organizations/warnings/components/organization-free-trial-warning.component.ts @@ -1,12 +1,9 @@ -import { AsyncPipe } from "@angular/common"; -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; -import { Observable, Subject } from "rxjs"; -import { takeUntil } from "rxjs/operators"; +import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { Observable } from "rxjs"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { OrganizationId } from "@bitwarden/common/types/guid"; -import { AnchorLinkDirective, BannerComponent } from "@bitwarden/components"; -import { I18nPipe } from "@bitwarden/ui-common"; +import { BannerModule } from "@bitwarden/components"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { OrganizationWarningsService } from "../services"; import { OrganizationFreeTrialWarning } from "../types"; @@ -37,33 +34,17 @@ import { OrganizationFreeTrialWarning } from "../types"; } `, - imports: [AnchorLinkDirective, AsyncPipe, BannerComponent, I18nPipe], + imports: [BannerModule, SharedModule], }) -export class OrganizationFreeTrialWarningComponent implements OnInit, OnDestroy { +export class OrganizationFreeTrialWarningComponent implements OnInit { @Input({ required: true }) organization!: Organization; @Output() clicked = new EventEmitter(); - warning$!: Observable; - private destroy$ = new Subject(); + warning$!: Observable; constructor(private organizationWarningsService: OrganizationWarningsService) {} ngOnInit() { this.warning$ = this.organizationWarningsService.getFreeTrialWarning$(this.organization); - this.organizationWarningsService - .refreshWarningsForOrganization$(this.organization.id as OrganizationId) - .pipe(takeUntil(this.destroy$)) - .subscribe(() => { - this.refresh(); - }); } - - ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); - } - - refresh = () => { - this.warning$ = this.organizationWarningsService.getFreeTrialWarning$(this.organization, true); - }; } diff --git a/apps/web/src/app/billing/warnings/components/organization-reseller-renewal-warning.component.ts b/apps/web/src/app/billing/organizations/warnings/components/organization-reseller-renewal-warning.component.ts similarity index 82% rename from apps/web/src/app/billing/warnings/components/organization-reseller-renewal-warning.component.ts rename to apps/web/src/app/billing/organizations/warnings/components/organization-reseller-renewal-warning.component.ts index f45dd443dda..4eba9f3daf5 100644 --- a/apps/web/src/app/billing/warnings/components/organization-reseller-renewal-warning.component.ts +++ b/apps/web/src/app/billing/organizations/warnings/components/organization-reseller-renewal-warning.component.ts @@ -1,9 +1,9 @@ -import { AsyncPipe } from "@angular/common"; import { Component, Input, OnInit } from "@angular/core"; import { Observable } from "rxjs"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { BannerComponent } from "@bitwarden/components"; +import { BannerModule } from "@bitwarden/components"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { OrganizationWarningsService } from "../services"; import { OrganizationResellerRenewalWarning } from "../types"; @@ -25,12 +25,12 @@ import { OrganizationResellerRenewalWarning } from "../types"; } `, - imports: [AsyncPipe, BannerComponent], + imports: [BannerModule, SharedModule], }) export class OrganizationResellerRenewalWarningComponent implements OnInit { @Input({ required: true }) organization!: Organization; - warning$!: Observable; + warning$!: Observable; constructor(private organizationWarningsService: OrganizationWarningsService) {} diff --git a/apps/web/src/app/billing/organizations/warnings/organization-warnings.module.ts b/apps/web/src/app/billing/organizations/warnings/organization-warnings.module.ts new file mode 100644 index 00000000000..6defee7e78b --- /dev/null +++ b/apps/web/src/app/billing/organizations/warnings/organization-warnings.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from "@angular/core"; + +import { + OrganizationBillingClient, + SubscriberBillingClient, +} from "@bitwarden/web-vault/app/billing/clients"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; + +@NgModule({ + providers: [OrganizationBillingClient, OrganizationWarningsService, SubscriberBillingClient], +}) +export class OrganizationWarningsModule {} diff --git a/apps/web/src/app/billing/warnings/services/index.ts b/apps/web/src/app/billing/organizations/warnings/services/index.ts similarity index 100% rename from apps/web/src/app/billing/warnings/services/index.ts rename to apps/web/src/app/billing/organizations/warnings/services/index.ts diff --git a/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts new file mode 100644 index 00000000000..c7a297cc28b --- /dev/null +++ b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts @@ -0,0 +1,682 @@ +jest.mock("@bitwarden/web-vault/app/billing/organizations/change-plan-dialog.component", () => ({ + ChangePlanDialogResultType: { + Submitted: "submitted", + Cancelled: "cancelled", + }, + openChangePlanDialog: jest.fn(), +})); + +import { TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; +import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.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 { DialogRef, DialogService } from "@bitwarden/components"; +import { OrganizationBillingClient } from "@bitwarden/web-vault/app/billing/clients"; +import { + ChangePlanDialogResultType, + openChangePlanDialog, +} from "@bitwarden/web-vault/app/billing/organizations/change-plan-dialog.component"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services/organization-warnings.service"; +import { OrganizationWarningsResponse } from "@bitwarden/web-vault/app/billing/organizations/warnings/types"; +import { + TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE, + TrialPaymentDialogComponent, + TrialPaymentDialogResultType, +} from "@bitwarden/web-vault/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component"; +import { TaxIdWarningTypes } from "@bitwarden/web-vault/app/billing/warnings/types"; + +describe("OrganizationWarningsService", () => { + let service: OrganizationWarningsService; + let configService: MockProxy; + let dialogService: MockProxy; + let i18nService: MockProxy; + let organizationApiService: MockProxy; + let organizationBillingClient: MockProxy; + let router: MockProxy; + + const organization = { + id: "org-id-123", + name: "Test Organization", + providerName: "Test Reseller Inc", + productTierType: ProductTierType.Enterprise, + } as Organization; + + const format = (date: Date): string => + date.toLocaleDateString("en-US", { + month: "short", + day: "2-digit", + year: "numeric", + }); + + beforeEach(() => { + configService = mock(); + dialogService = mock(); + i18nService = mock(); + organizationApiService = mock(); + organizationBillingClient = mock(); + router = mock(); + + (openChangePlanDialog as jest.Mock).mockReset(); + + i18nService.t.mockImplementation((key: string, ...args: any[]) => { + switch (key) { + case "freeTrialEndPromptCount": + return `Your free trial ends in ${args[0]} days.`; + case "freeTrialEndPromptTomorrowNoOrgName": + return "Your free trial ends tomorrow."; + case "freeTrialEndingTodayWithoutOrgName": + return "Your free trial ends today."; + case "resellerRenewalWarningMsg": + return `Your subscription will renew soon. To ensure uninterrupted service, contact ${args[0]} to confirm your renewal before ${args[1]}.`; + case "resellerOpenInvoiceWarningMgs": + return `An invoice for your subscription was issued on ${args[1]}. To ensure uninterrupted service, contact ${args[0]} to confirm your renewal before ${args[2]}.`; + case "resellerPastDueWarningMsg": + return `The invoice for your subscription has not been paid. To ensure uninterrupted service, contact ${args[0]} to confirm your renewal before ${args[1]}.`; + case "suspendedOrganizationTitle": + return `${args[0]} subscription suspended`; + case "close": + return "Close"; + case "continue": + return "Continue"; + default: + return key; + } + }); + + TestBed.configureTestingModule({ + providers: [ + OrganizationWarningsService, + { provide: ConfigService, useValue: configService }, + { provide: DialogService, useValue: dialogService }, + { provide: I18nService, useValue: i18nService }, + { provide: OrganizationApiServiceAbstraction, useValue: organizationApiService }, + { provide: OrganizationBillingClient, useValue: organizationBillingClient }, + { provide: Router, useValue: router }, + ], + }); + + service = TestBed.inject(OrganizationWarningsService); + }); + + describe("getFreeTrialWarning$", () => { + it("should return null when no free trial warning exists", (done) => { + organizationBillingClient.getWarnings.mockResolvedValue({} as OrganizationWarningsResponse); + + service.getFreeTrialWarning$(organization).subscribe((result) => { + expect(result).toBeNull(); + done(); + }); + }); + + it("should return warning with count message when remaining trial days >= 2", (done) => { + const warning = { remainingTrialDays: 5 }; + organizationBillingClient.getWarnings.mockResolvedValue({ + freeTrial: warning, + } as OrganizationWarningsResponse); + + service.getFreeTrialWarning$(organization).subscribe((result) => { + expect(result).toEqual({ + organization: organization, + message: "Your free trial ends in 5 days.", + }); + expect(i18nService.t).toHaveBeenCalledWith("freeTrialEndPromptCount", 5); + done(); + }); + }); + + it("should return warning with tomorrow message when remaining trial days = 1", (done) => { + const warning = { remainingTrialDays: 1 }; + organizationBillingClient.getWarnings.mockResolvedValue({ + freeTrial: warning, + } as OrganizationWarningsResponse); + + service.getFreeTrialWarning$(organization).subscribe((result) => { + expect(result).toEqual({ + organization: organization, + message: "Your free trial ends tomorrow.", + }); + expect(i18nService.t).toHaveBeenCalledWith("freeTrialEndPromptTomorrowNoOrgName"); + done(); + }); + }); + + it("should return warning with today message when remaining trial days = 0", (done) => { + const warning = { remainingTrialDays: 0 }; + organizationBillingClient.getWarnings.mockResolvedValue({ + freeTrial: warning, + } as OrganizationWarningsResponse); + + service.getFreeTrialWarning$(organization).subscribe((result) => { + expect(result).toEqual({ + organization: organization, + message: "Your free trial ends today.", + }); + expect(i18nService.t).toHaveBeenCalledWith("freeTrialEndingTodayWithoutOrgName"); + done(); + }); + }); + + it("should refresh warning when refreshFreeTrialWarning is called", (done) => { + const initialWarning = { remainingTrialDays: 3 }; + const refreshedWarning = { remainingTrialDays: 2 }; + let invocationCount = 0; + + organizationBillingClient.getWarnings + .mockResolvedValueOnce({ + freeTrial: initialWarning, + } as OrganizationWarningsResponse) + .mockResolvedValueOnce({ + freeTrial: refreshedWarning, + } as OrganizationWarningsResponse); + + const subscription = service.getFreeTrialWarning$(organization).subscribe((result) => { + invocationCount++; + + if (invocationCount === 1) { + expect(result).toEqual({ + organization: organization, + message: "Your free trial ends in 3 days.", + }); + } else if (invocationCount === 2) { + expect(result).toEqual({ + organization: organization, + message: "Your free trial ends in 2 days.", + }); + subscription.unsubscribe(); + done(); + } + }); + + setTimeout(() => { + service.refreshFreeTrialWarning(); + }, 10); + }); + }); + + describe("getResellerRenewalWarning$", () => { + it("should return null when no reseller renewal warning exists", (done) => { + organizationBillingClient.getWarnings.mockResolvedValue({} as OrganizationWarningsResponse); + + service.getResellerRenewalWarning$(organization).subscribe((result) => { + expect(result).toBeNull(); + done(); + }); + }); + + it("should return upcoming warning with correct type and message", (done) => { + const renewalDate = new Date(2024, 11, 31); + const warning = { + type: "upcoming" as const, + upcoming: { renewalDate }, + }; + organizationBillingClient.getWarnings.mockResolvedValue({ + resellerRenewal: warning, + } as OrganizationWarningsResponse); + + service.getResellerRenewalWarning$(organization).subscribe((result) => { + const expectedFormattedDate = format(renewalDate); + + expect(result).toEqual({ + type: "info", + message: `Your subscription will renew soon. To ensure uninterrupted service, contact Test Reseller Inc to confirm your renewal before ${expectedFormattedDate}.`, + }); + expect(i18nService.t).toHaveBeenCalledWith( + "resellerRenewalWarningMsg", + "Test Reseller Inc", + expectedFormattedDate, + ); + done(); + }); + }); + + it("should return issued warning with correct type and message", (done) => { + const issuedDate = new Date(2024, 10, 15); + const dueDate = new Date(2024, 11, 15); + const warning = { + type: "issued" as const, + issued: { issuedDate, dueDate }, + }; + organizationBillingClient.getWarnings.mockResolvedValue({ + resellerRenewal: warning, + } as OrganizationWarningsResponse); + + service.getResellerRenewalWarning$(organization).subscribe((result) => { + const expectedIssuedDate = format(issuedDate); + const expectedDueDate = format(dueDate); + + expect(result).toEqual({ + type: "info", + message: `An invoice for your subscription was issued on ${expectedIssuedDate}. To ensure uninterrupted service, contact Test Reseller Inc to confirm your renewal before ${expectedDueDate}.`, + }); + expect(i18nService.t).toHaveBeenCalledWith( + "resellerOpenInvoiceWarningMgs", + "Test Reseller Inc", + expectedIssuedDate, + expectedDueDate, + ); + done(); + }); + }); + + it("should return past_due warning with correct type and message", (done) => { + const suspensionDate = new Date(2024, 11, 1); + const warning = { + type: "past_due" as const, + pastDue: { suspensionDate }, + }; + organizationBillingClient.getWarnings.mockResolvedValue({ + resellerRenewal: warning, + } as OrganizationWarningsResponse); + + service.getResellerRenewalWarning$(organization).subscribe((result) => { + const expectedSuspensionDate = format(suspensionDate); + + expect(result).toEqual({ + type: "warning", + message: `The invoice for your subscription has not been paid. To ensure uninterrupted service, contact Test Reseller Inc to confirm your renewal before ${expectedSuspensionDate}.`, + }); + expect(i18nService.t).toHaveBeenCalledWith( + "resellerPastDueWarningMsg", + "Test Reseller Inc", + expectedSuspensionDate, + ); + done(); + }); + }); + }); + + describe("getTaxIdWarning$", () => { + it("should return null when no tax ID warning exists", (done) => { + organizationBillingClient.getWarnings.mockResolvedValue({} as OrganizationWarningsResponse); + + service.getTaxIdWarning$(organization).subscribe((result) => { + expect(result).toBeNull(); + done(); + }); + }); + + it("should return tax_id_missing type when tax ID is missing", (done) => { + const warning = { type: TaxIdWarningTypes.Missing }; + organizationBillingClient.getWarnings.mockResolvedValue({ + taxId: warning, + } as OrganizationWarningsResponse); + + service.getTaxIdWarning$(organization).subscribe((result) => { + expect(result).toBe(TaxIdWarningTypes.Missing); + done(); + }); + }); + + it("should return tax_id_pending_verification type when tax ID verification is pending", (done) => { + const warning = { type: TaxIdWarningTypes.PendingVerification }; + organizationBillingClient.getWarnings.mockResolvedValue({ + taxId: warning, + } as OrganizationWarningsResponse); + + service.getTaxIdWarning$(organization).subscribe((result) => { + expect(result).toBe(TaxIdWarningTypes.PendingVerification); + done(); + }); + }); + + it("should return tax_id_failed_verification type when tax ID verification failed", (done) => { + const warning = { type: TaxIdWarningTypes.FailedVerification }; + organizationBillingClient.getWarnings.mockResolvedValue({ + taxId: warning, + } as OrganizationWarningsResponse); + + service.getTaxIdWarning$(organization).subscribe((result) => { + expect(result).toBe(TaxIdWarningTypes.FailedVerification); + done(); + }); + }); + + it("should refresh warning and update taxIdWarningRefreshedSubject when refreshTaxIdWarning is called", (done) => { + const initialWarning = { type: TaxIdWarningTypes.Missing }; + const refreshedWarning = { type: TaxIdWarningTypes.FailedVerification }; + let invocationCount = 0; + + organizationBillingClient.getWarnings + .mockResolvedValueOnce({ + taxId: initialWarning, + } as OrganizationWarningsResponse) + .mockResolvedValueOnce({ + taxId: refreshedWarning, + } as OrganizationWarningsResponse); + + const subscription = service.getTaxIdWarning$(organization).subscribe((result) => { + invocationCount++; + + if (invocationCount === 1) { + expect(result).toBe(TaxIdWarningTypes.Missing); + } else if (invocationCount === 2) { + expect(result).toBe(TaxIdWarningTypes.FailedVerification); + subscription.unsubscribe(); + done(); + } + }); + + setTimeout(() => { + service.refreshTaxIdWarning(); + }, 10); + }); + + it("should update taxIdWarningRefreshedSubject with warning type when refresh returns a warning", (done) => { + const refreshedWarning = { type: TaxIdWarningTypes.Missing }; + let refreshedCount = 0; + + organizationBillingClient.getWarnings + .mockResolvedValueOnce({} as OrganizationWarningsResponse) + .mockResolvedValueOnce({ + taxId: refreshedWarning, + } as OrganizationWarningsResponse); + + const taxIdSubscription = service.taxIdWarningRefreshed$.subscribe((refreshedType) => { + refreshedCount++; + if (refreshedCount === 2) { + expect(refreshedType).toBe(TaxIdWarningTypes.Missing); + taxIdSubscription.unsubscribe(); + done(); + } + }); + + service.getTaxIdWarning$(organization).subscribe(); + + setTimeout(() => { + service.refreshTaxIdWarning(); + }, 10); + }); + + it("should update taxIdWarningRefreshedSubject with null when refresh returns no warning", (done) => { + const initialWarning = { type: TaxIdWarningTypes.Missing }; + let refreshedCount = 0; + + organizationBillingClient.getWarnings + .mockResolvedValueOnce({ + taxId: initialWarning, + } as OrganizationWarningsResponse) + .mockResolvedValueOnce({} as OrganizationWarningsResponse); + + const taxIdSubscription = service.taxIdWarningRefreshed$.subscribe((refreshedType) => { + refreshedCount++; + if (refreshedCount === 2) { + expect(refreshedType).toBeNull(); + taxIdSubscription.unsubscribe(); + done(); + } + }); + + service.getTaxIdWarning$(organization).subscribe(); + + setTimeout(() => { + service.refreshTaxIdWarning(); + }, 10); + }); + }); + + describe("showInactiveSubscriptionDialog$", () => { + it("should not show dialog when no inactive subscription warning exists", (done) => { + organizationBillingClient.getWarnings.mockResolvedValue({} as OrganizationWarningsResponse); + + service.showInactiveSubscriptionDialog$(organization).subscribe({ + complete: () => { + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + done(); + }, + }); + }); + + it("should show contact provider dialog for contact_provider resolution", (done) => { + const warning = { resolution: "contact_provider" }; + organizationBillingClient.getWarnings.mockResolvedValue({ + inactiveSubscription: warning, + } as OrganizationWarningsResponse); + + dialogService.openSimpleDialog.mockResolvedValue(true); + + service.showInactiveSubscriptionDialog$(organization).subscribe({ + complete: () => { + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: "Test Organization subscription suspended", + content: { + key: "suspendedManagedOrgMessage", + placeholders: ["Test Reseller Inc"], + }, + type: "danger", + acceptButtonText: "Close", + cancelButtonText: null, + }); + done(); + }, + }); + }); + + it("should show add payment method dialog and navigate when confirmed", (done) => { + const warning = { resolution: "add_payment_method" }; + organizationBillingClient.getWarnings.mockResolvedValue({ + inactiveSubscription: warning, + } as OrganizationWarningsResponse); + + dialogService.openSimpleDialog.mockResolvedValue(true); + configService.getFeatureFlag.mockResolvedValue(false); + router.navigate.mockResolvedValue(true); + + service.showInactiveSubscriptionDialog$(organization).subscribe({ + complete: () => { + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: "Test Organization subscription suspended", + content: { key: "suspendedOwnerOrgMessage" }, + type: "danger", + acceptButtonText: "Continue", + cancelButtonText: "Close", + }); + expect(configService.getFeatureFlag).toHaveBeenCalledWith( + FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, + ); + expect(router.navigate).toHaveBeenCalledWith( + ["organizations", "org-id-123", "billing", "payment-method"], + { state: { launchPaymentModalAutomatically: true } }, + ); + done(); + }, + }); + }); + + it("should navigate to payment-details when feature flag is enabled", (done) => { + const warning = { resolution: "add_payment_method" }; + organizationBillingClient.getWarnings.mockResolvedValue({ + inactiveSubscription: warning, + } as OrganizationWarningsResponse); + + dialogService.openSimpleDialog.mockResolvedValue(true); + configService.getFeatureFlag.mockResolvedValue(true); + router.navigate.mockResolvedValue(true); + + service.showInactiveSubscriptionDialog$(organization).subscribe({ + complete: () => { + expect(router.navigate).toHaveBeenCalledWith( + ["organizations", "org-id-123", "billing", "payment-details"], + { state: { launchPaymentModalAutomatically: true } }, + ); + done(); + }, + }); + }); + + it("should not navigate when add payment method dialog is cancelled", (done) => { + const warning = { resolution: "add_payment_method" }; + organizationBillingClient.getWarnings.mockResolvedValue({ + inactiveSubscription: warning, + } as OrganizationWarningsResponse); + + dialogService.openSimpleDialog.mockResolvedValue(false); + + service.showInactiveSubscriptionDialog$(organization).subscribe({ + complete: () => { + expect(dialogService.openSimpleDialog).toHaveBeenCalled(); + expect(configService.getFeatureFlag).not.toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + done(); + }, + }); + }); + + it("should open change plan dialog for resubscribe resolution", (done) => { + const warning = { resolution: "resubscribe" }; + const subscription = { id: "sub-123" } as OrganizationSubscriptionResponse; + + organizationBillingClient.getWarnings.mockResolvedValue({ + inactiveSubscription: warning, + } as OrganizationWarningsResponse); + + organizationApiService.getSubscription.mockResolvedValue(subscription); + + const mockDialogRef = { + closed: of("submitted"), + } as DialogRef; + + (openChangePlanDialog as jest.Mock).mockReturnValue(mockDialogRef); + + service.showInactiveSubscriptionDialog$(organization).subscribe({ + complete: () => { + expect(organizationApiService.getSubscription).toHaveBeenCalledWith(organization.id); + expect(openChangePlanDialog).toHaveBeenCalledWith(dialogService, { + data: { + organizationId: organization.id, + subscription: subscription, + productTierType: organization.productTierType, + }, + }); + done(); + }, + }); + }); + + it("should show contact owner dialog for contact_owner resolution", (done) => { + const warning = { resolution: "contact_owner" }; + organizationBillingClient.getWarnings.mockResolvedValue({ + inactiveSubscription: warning, + } as OrganizationWarningsResponse); + + dialogService.openSimpleDialog.mockResolvedValue(true); + + service.showInactiveSubscriptionDialog$(organization).subscribe({ + complete: () => { + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: "Test Organization subscription suspended", + content: { key: "suspendedUserOrgMessage" }, + type: "danger", + acceptButtonText: "Close", + cancelButtonText: null, + }); + done(); + }, + }); + }); + }); + + describe("showSubscribeBeforeFreeTrialEndsDialog$", () => { + it("should not show dialog when no free trial warning exists", (done) => { + organizationBillingClient.getWarnings.mockResolvedValue({} as OrganizationWarningsResponse); + + service.showSubscribeBeforeFreeTrialEndsDialog$(organization).subscribe({ + complete: () => { + expect(organizationApiService.getSubscription).not.toHaveBeenCalled(); + done(); + }, + }); + }); + + it("should open trial payment dialog when free trial warning exists", (done) => { + const warning = { remainingTrialDays: 2 }; + const subscription = { id: "sub-123" } as OrganizationSubscriptionResponse; + + organizationBillingClient.getWarnings.mockResolvedValue({ + freeTrial: warning, + } as OrganizationWarningsResponse); + + organizationApiService.getSubscription.mockResolvedValue(subscription); + + const mockDialogRef = { + closed: of(TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.CLOSED), + } as DialogRef; + + const openSpy = jest + .spyOn(TrialPaymentDialogComponent, "open") + .mockReturnValue(mockDialogRef); + + service.showSubscribeBeforeFreeTrialEndsDialog$(organization).subscribe({ + complete: () => { + expect(organizationApiService.getSubscription).toHaveBeenCalledWith(organization.id); + expect(openSpy).toHaveBeenCalledWith(dialogService, { + data: { + organizationId: organization.id, + subscription: subscription, + productTierType: organization.productTierType, + }, + }); + done(); + }, + }); + }); + + it("should refresh free trial warning when dialog result is SUBMITTED", (done) => { + const warning = { remainingTrialDays: 1 }; + const subscription = { id: "sub-456" } as OrganizationSubscriptionResponse; + + organizationBillingClient.getWarnings.mockResolvedValue({ + freeTrial: warning, + } as OrganizationWarningsResponse); + + organizationApiService.getSubscription.mockResolvedValue(subscription); + + const mockDialogRef = { + closed: of(TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED), + } as DialogRef; + + jest.spyOn(TrialPaymentDialogComponent, "open").mockReturnValue(mockDialogRef); + + const refreshTriggerSpy = jest.spyOn(service["refreshFreeTrialWarningTrigger"], "next"); + + service.showSubscribeBeforeFreeTrialEndsDialog$(organization).subscribe({ + complete: () => { + expect(refreshTriggerSpy).toHaveBeenCalled(); + done(); + }, + }); + }); + + it("should not refresh free trial warning when dialog result is CLOSED", (done) => { + const warning = { remainingTrialDays: 3 }; + const subscription = { id: "sub-789" } as OrganizationSubscriptionResponse; + + organizationBillingClient.getWarnings.mockResolvedValue({ + freeTrial: warning, + } as OrganizationWarningsResponse); + + organizationApiService.getSubscription.mockResolvedValue(subscription); + + const mockDialogRef = { + closed: of(TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.CLOSED), + } as DialogRef; + + jest.spyOn(TrialPaymentDialogComponent, "open").mockReturnValue(mockDialogRef); + const refreshSpy = jest.spyOn(service, "refreshFreeTrialWarning"); + + service.showSubscribeBeforeFreeTrialEndsDialog$(organization).subscribe({ + complete: () => { + expect(refreshSpy).not.toHaveBeenCalled(); + done(); + }, + }); + }); + }); +}); diff --git a/apps/web/src/app/billing/warnings/services/organization-warnings.service.ts b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts similarity index 63% rename from apps/web/src/app/billing/warnings/services/organization-warnings.service.ts rename to apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts index 78c17a5d384..5b466dfe41d 100644 --- a/apps/web/src/app/billing/warnings/services/organization-warnings.service.ts +++ b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts @@ -1,26 +1,39 @@ -import { Location } from "@angular/common"; import { Injectable } from "@angular/core"; import { Router } from "@angular/router"; -import { filter, from, lastValueFrom, map, Observable, Subject, switchMap, takeWhile } from "rxjs"; +import { + BehaviorSubject, + filter, + from, + lastValueFrom, + map, + merge, + Observable, + Subject, + switchMap, + tap, +} from "rxjs"; import { take } from "rxjs/operators"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction"; -import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/response/organization-warnings.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 { SyncService } from "@bitwarden/common/platform/sync"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; +import { OrganizationBillingClient } from "@bitwarden/web-vault/app/billing/clients"; +import { TaxIdWarningType } from "@bitwarden/web-vault/app/billing/warnings/types"; -import { openChangePlanDialog } from "../../organizations/change-plan-dialog.component"; import { TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE, TrialPaymentDialogComponent, -} from "../../shared/trial-payment-dialog/trial-payment-dialog.component"; -import { OrganizationFreeTrialWarning, OrganizationResellerRenewalWarning } from "../types"; +} from "../../../shared/trial-payment-dialog/trial-payment-dialog.component"; +import { openChangePlanDialog } from "../../change-plan-dialog.component"; +import { + OrganizationFreeTrialWarning, + OrganizationResellerRenewalWarning, + OrganizationWarningsResponse, +} from "../types"; const format = (date: Date) => date.toLocaleDateString("en-US", { @@ -29,28 +42,39 @@ const format = (date: Date) => year: "numeric", }); -@Injectable({ providedIn: "root" }) +@Injectable() export class OrganizationWarningsService { private cache$ = new Map>(); - private refreshWarnings$ = new Subject(); + + private refreshFreeTrialWarningTrigger = new Subject(); + private refreshTaxIdWarningTrigger = new Subject(); + + private taxIdWarningRefreshedSubject = new BehaviorSubject(null); + taxIdWarningRefreshed$ = this.taxIdWarningRefreshedSubject.asObservable(); constructor( private configService: ConfigService, private dialogService: DialogService, private i18nService: I18nService, private organizationApiService: OrganizationApiServiceAbstraction, - private organizationBillingApiService: OrganizationBillingApiServiceAbstraction, + private organizationBillingClient: OrganizationBillingClient, private router: Router, - private location: Location, - protected syncService: SyncService, ) {} getFreeTrialWarning$ = ( organization: Organization, - bypassCache: boolean = false, - ): Observable => - this.getWarning$(organization, (response) => response.freeTrial, bypassCache).pipe( + ): Observable => + merge( + this.getWarning$(organization, (response) => response.freeTrial), + this.refreshFreeTrialWarningTrigger.pipe( + switchMap(() => this.getWarning$(organization, (response) => response.freeTrial, true)), + ), + ).pipe( map((warning) => { + if (!warning) { + return null; + } + const { remainingTrialDays } = warning; if (remainingTrialDays >= 2) { @@ -76,10 +100,12 @@ export class OrganizationWarningsService { getResellerRenewalWarning$ = ( organization: Organization, - bypassCache: boolean = false, - ): Observable => - this.getWarning$(organization, (response) => response.resellerRenewal, bypassCache).pipe( - map((warning): OrganizationResellerRenewalWarning | null => { + ): Observable => + this.getWarning$(organization, (response) => response.resellerRenewal).pipe( + map((warning) => { + if (!warning) { + return null; + } switch (warning.type) { case "upcoming": { return { @@ -114,14 +140,27 @@ export class OrganizationWarningsService { } } }), - filter((result): result is NonNullable => result !== null), ); - showInactiveSubscriptionDialog$ = ( - organization: Organization, - bypassCache: boolean = false, - ): Observable => - this.getWarning$(organization, (response) => response.inactiveSubscription, bypassCache).pipe( + getTaxIdWarning$ = (organization: Organization): Observable => + merge( + this.getWarning$(organization, (response) => response.taxId), + this.refreshTaxIdWarningTrigger.pipe( + switchMap(() => + this.getWarning$(organization, (response) => response.taxId, true).pipe( + tap((warning) => this.taxIdWarningRefreshedSubject.next(warning ? warning.type : null)), + ), + ), + ), + ).pipe(map((warning) => (warning ? warning.type : null))); + + refreshFreeTrialWarning = () => this.refreshFreeTrialWarningTrigger.next(); + + refreshTaxIdWarning = () => this.refreshTaxIdWarningTrigger.next(); + + showInactiveSubscriptionDialog$ = (organization: Organization): Observable => + this.getWarning$(organization, (response) => response.inactiveSubscription).pipe( + filter((warning) => warning !== null), switchMap(async (warning) => { switch (warning.resolution) { case "contact_provider": { @@ -183,43 +222,43 @@ export class OrganizationWarningsService { }); break; } - case "add_payment_method_optional_trial": { - const organizationSubscriptionResponse = - await this.organizationApiService.getSubscription(organization.id); - - const dialogRef = TrialPaymentDialogComponent.open(this.dialogService, { - data: { - organizationId: organization.id, - subscription: organizationSubscriptionResponse, - productTierType: organization?.productTierType, - }, - }); - const result = await lastValueFrom(dialogRef.closed); - if (result === TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED) { - this.refreshWarnings$.next(organization.id as OrganizationId); - } - } } }), ); - refreshWarningsForOrganization$(organizationId: OrganizationId): Observable { - return this.refreshWarnings$.pipe( - filter((id) => id === organizationId), - map((): void => void 0), - ); - } + showSubscribeBeforeFreeTrialEndsDialog$ = (organization: Organization): Observable => + this.getWarning$(organization, (response) => response.freeTrial).pipe( + filter((warning) => warning !== null), + switchMap(async () => { + const organizationSubscriptionResponse = await this.organizationApiService.getSubscription( + organization.id, + ); - private getResponse$ = ( + const dialogRef = TrialPaymentDialogComponent.open(this.dialogService, { + data: { + organizationId: organization.id, + subscription: organizationSubscriptionResponse, + productTierType: organization?.productTierType, + }, + }); + const result = await lastValueFrom(dialogRef.closed); + if (result === TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED) { + this.refreshFreeTrialWarningTrigger.next(); + } + }), + ); + + private readThroughWarnings$ = ( organization: Organization, bypassCache: boolean = false, ): Observable => { - const existing = this.cache$.get(organization.id as OrganizationId); + const organizationId = organization.id as OrganizationId; + const existing = this.cache$.get(organizationId); if (existing && !bypassCache) { return existing; } - const response$ = from(this.organizationBillingApiService.getWarnings(organization.id)); - this.cache$.set(organization.id as OrganizationId, response$); + const response$ = from(this.organizationBillingClient.getWarnings(organizationId)); + this.cache$.set(organizationId, response$); return response$; }; @@ -227,10 +266,12 @@ export class OrganizationWarningsService { organization: Organization, extract: (response: OrganizationWarningsResponse) => T | null | undefined, bypassCache: boolean = false, - ): Observable => - this.getResponse$(organization, bypassCache).pipe( - map(extract), - takeWhile((warning): warning is T => !!warning), + ): Observable => + this.readThroughWarnings$(organization, bypassCache).pipe( + map((response) => { + const value = extract(response); + return value ? value : null; + }), take(1), ); } diff --git a/apps/web/src/app/billing/organizations/warnings/types/index.ts b/apps/web/src/app/billing/organizations/warnings/types/index.ts new file mode 100644 index 00000000000..fc0c7d278ed --- /dev/null +++ b/apps/web/src/app/billing/organizations/warnings/types/index.ts @@ -0,0 +1 @@ +export * from "./organization-warnings"; diff --git a/libs/common/src/billing/models/response/organization-warnings.response.ts b/apps/web/src/app/billing/organizations/warnings/types/organization-warnings.ts similarity index 80% rename from libs/common/src/billing/models/response/organization-warnings.response.ts rename to apps/web/src/app/billing/organizations/warnings/types/organization-warnings.ts index ff70298101e..0c0097d5b09 100644 --- a/libs/common/src/billing/models/response/organization-warnings.response.ts +++ b/apps/web/src/app/billing/organizations/warnings/types/organization-warnings.ts @@ -1,9 +1,22 @@ -import { BaseResponse } from "../../../models/response/base.response"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { TaxIdWarningResponse } from "@bitwarden/web-vault/app/billing/warnings/types"; + +export type OrganizationFreeTrialWarning = { + organization: Pick; + message: string; +}; + +export type OrganizationResellerRenewalWarning = { + type: "info" | "warning"; + message: string; +}; export class OrganizationWarningsResponse extends BaseResponse { freeTrial?: FreeTrialWarningResponse; inactiveSubscription?: InactiveSubscriptionWarningResponse; resellerRenewal?: ResellerRenewalWarningResponse; + taxId?: TaxIdWarningResponse; constructor(response: any) { super(response); @@ -21,6 +34,10 @@ export class OrganizationWarningsResponse extends BaseResponse { if (resellerWarning) { this.resellerRenewal = new ResellerRenewalWarningResponse(resellerWarning); } + const taxIdWarning = this.getResponseProperty("TaxId"); + if (taxIdWarning) { + this.taxId = new TaxIdWarningResponse(taxIdWarning); + } } } diff --git a/apps/web/src/app/billing/payment/components/add-account-credit-dialog.component.ts b/apps/web/src/app/billing/payment/components/add-account-credit-dialog.component.ts index 90705abddd3..a83a00e8158 100644 --- a/apps/web/src/app/billing/payment/components/add-account-credit-dialog.component.ts +++ b/apps/web/src/app/billing/payment/components/add-account-credit-dialog.component.ts @@ -14,13 +14,13 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; import { SharedModule } from "../../../shared"; -import { BillingClient } from "../../services"; -import { BillableEntity } from "../../types"; +import { BitwardenSubscriber } from "../../types"; type DialogParams = { - owner: BillableEntity; + subscriber: BitwardenSubscriber; }; type DialogResult = "cancelled" | "error" | "launched"; @@ -125,7 +125,7 @@ const positiveNumberValidator = `, standalone: true, imports: [SharedModule], - providers: [BillingClient], + providers: [SubscriberBillingClient], }) export class AddAccountCreditDialogComponent { @ViewChild("payPalForm", { read: ElementRef, static: true }) payPalForm!: ElementRef; @@ -143,22 +143,22 @@ export class AddAccountCreditDialogComponent { protected payPalCustom$ = this.configService.cloudRegion$.pipe( map((cloudRegion) => { - switch (this.dialogParams.owner.type) { + switch (this.dialogParams.subscriber.type) { case "account": { - return `user_id:${this.dialogParams.owner.data.id},account_credit:1,region:${cloudRegion}`; + return `user_id:${this.dialogParams.subscriber.data.id},account_credit:1,region:${cloudRegion}`; } case "organization": { - return `organization_id:${this.dialogParams.owner.data.id},account_credit:1,region:${cloudRegion}`; + return `organization_id:${this.dialogParams.subscriber.data.id},account_credit:1,region:${cloudRegion}`; } case "provider": { - return `provider_id:${this.dialogParams.owner.data.id},account_credit:1,region:${cloudRegion}`; + return `provider_id:${this.dialogParams.subscriber.data.id},account_credit:1,region:${cloudRegion}`; } } }), ); constructor( - private billingClient: BillingClient, + private billingClient: SubscriberBillingClient, private configService: ConfigService, @Inject(DIALOG_DATA) private dialogParams: DialogParams, private dialogRef: DialogRef, @@ -175,7 +175,7 @@ export class AddAccountCreditDialogComponent { } if (this.formGroup.value.paymentMethod === "bitPay") { - const result = await this.billingClient.addCreditWithBitPay(this.dialogParams.owner, { + const result = await this.billingClient.addCreditWithBitPay(this.dialogParams.subscriber, { amount: this.amount!, redirectUrl: this.redirectUrl, }); @@ -225,13 +225,13 @@ export class AddAccountCreditDialogComponent { } get payPalSubject(): string { - switch (this.dialogParams.owner.type) { + switch (this.dialogParams.subscriber.type) { case "account": { - return this.dialogParams.owner.data.email; + return this.dialogParams.subscriber.data.email; } case "organization": case "provider": { - return this.dialogParams.owner.data.name; + return this.dialogParams.subscriber.data.name; } } } diff --git a/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts b/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts index 15c63d8f99f..4d2fadaa894 100644 --- a/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts +++ b/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts @@ -3,10 +3,10 @@ import { Component, Inject } from "@angular/core"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; import { SharedModule } from "../../../shared"; -import { BillingClient } from "../../services"; -import { BillableEntity } from "../../types"; +import { BitwardenSubscriber } from "../../types"; import { EnterPaymentMethodComponent } from "./enter-payment-method.component"; import { @@ -15,7 +15,7 @@ import { } from "./submit-payment-method-dialog.component"; type DialogParams = { - owner: BillableEntity; + subscriber: BitwardenSubscriber; }; @Component({ @@ -28,7 +28,7 @@ type DialogParams = {
@@ -51,20 +51,20 @@ type DialogParams = { `, standalone: true, imports: [EnterPaymentMethodComponent, SharedModule], - providers: [BillingClient], + providers: [SubscriberBillingClient], }) export class ChangePaymentMethodDialogComponent extends SubmitPaymentMethodDialogComponent { - protected override owner: BillableEntity; + protected override subscriber: BitwardenSubscriber; constructor( - billingClient: BillingClient, + billingClient: SubscriberBillingClient, @Inject(DIALOG_DATA) protected dialogParams: DialogParams, dialogRef: DialogRef, i18nService: I18nService, toastService: ToastService, ) { super(billingClient, dialogRef, i18nService, toastService); - this.owner = this.dialogParams.owner; + this.subscriber = this.dialogParams.subscriber; } static open = (dialogService: DialogService, dialogConfig: DialogConfig) => diff --git a/apps/web/src/app/billing/payment/components/display-account-credit.component.ts b/apps/web/src/app/billing/payment/components/display-account-credit.component.ts index 7cbe3a27f30..f6aa0ef58bb 100644 --- a/apps/web/src/app/billing/payment/components/display-account-credit.component.ts +++ b/apps/web/src/app/billing/payment/components/display-account-credit.component.ts @@ -3,10 +3,10 @@ import { Component, Input } from "@angular/core"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { DialogService, ToastService } from "@bitwarden/components"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; import { SharedModule } from "../../../shared"; -import { BillingClient } from "../../services"; -import { BillableEntity } from "../../types"; +import { BitwardenSubscriber } from "../../types"; import { AddAccountCreditDialogComponent } from "./add-account-credit-dialog.component"; @@ -23,14 +23,14 @@ import { AddAccountCreditDialogComponent } from "./add-account-credit-dialog.com `, standalone: true, imports: [SharedModule], - providers: [BillingClient, CurrencyPipe], + providers: [SubscriberBillingClient, CurrencyPipe], }) export class DisplayAccountCreditComponent { - @Input({ required: true }) owner!: BillableEntity; + @Input({ required: true }) subscriber!: BitwardenSubscriber; @Input({ required: true }) credit!: number | null; constructor( - private billingClient: BillingClient, + private billingClient: SubscriberBillingClient, private currencyPipe: CurrencyPipe, private dialogService: DialogService, private i18nService: I18nService, @@ -38,8 +38,8 @@ export class DisplayAccountCreditComponent { ) {} addAccountCredit = async () => { - if (this.owner.type !== "account") { - const billingAddress = await this.billingClient.getBillingAddress(this.owner); + if (this.subscriber.type !== "account") { + const billingAddress = await this.billingClient.getBillingAddress(this.subscriber); if (!billingAddress) { this.toastService.showToast({ variant: "error", @@ -51,7 +51,7 @@ export class DisplayAccountCreditComponent { AddAccountCreditDialogComponent.open(this.dialogService, { data: { - owner: this.owner, + subscriber: this.subscriber, }, }); }; diff --git a/apps/web/src/app/billing/payment/components/display-billing-address.component.ts b/apps/web/src/app/billing/payment/components/display-billing-address.component.ts index f0a11321e5d..03d21a79003 100644 --- a/apps/web/src/app/billing/payment/components/display-billing-address.component.ts +++ b/apps/web/src/app/billing/payment/components/display-billing-address.component.ts @@ -2,23 +2,38 @@ import { Component, EventEmitter, Input, Output } from "@angular/core"; import { lastValueFrom } from "rxjs"; import { DialogService } from "@bitwarden/components"; - -import { SharedModule } from "../../../shared"; -import { BillableEntity } from "../../types"; -import { AddressPipe } from "../pipes"; -import { BillingAddress } from "../types"; - -import { EditBillingAddressDialogComponent } from "./edit-billing-address-dialog.component"; +import { EditBillingAddressDialogComponent } from "@bitwarden/web-vault/app/billing/payment/components/edit-billing-address-dialog.component"; +import { AddressPipe } from "@bitwarden/web-vault/app/billing/payment/pipes"; +import { BillingAddress } from "@bitwarden/web-vault/app/billing/payment/types"; +import { BitwardenSubscriber } from "@bitwarden/web-vault/app/billing/types"; +import { + TaxIdWarningType, + TaxIdWarningTypes, +} from "@bitwarden/web-vault/app/billing/warnings/types"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; @Component({ selector: "app-display-billing-address", template: ` -

{{ "billingAddress" | i18n }}

+

+ {{ "billingAddress" | i18n }} + @if (showMissingTaxIdBadge) { + {{ "missingTaxId" | i18n }} + } +

@if (billingAddress) {

{{ billingAddress | address }}

@if (billingAddress.taxId) { -

{{ "taxId" | i18n: billingAddress.taxId.value }}

+

+ {{ "taxId" | i18n: billingAddress.taxId.value }} + @if (showTaxIdPendingVerificationBadge) { + {{ "pendingVerification" | i18n }} + } + @if (showUnverifiedTaxIdBadge) { + {{ "unverified" | i18n }} + } +

} } @else {

{{ "noBillingAddress" | i18n }}

@@ -33,8 +48,9 @@ import { EditBillingAddressDialogComponent } from "./edit-billing-address-dialog imports: [AddressPipe, SharedModule], }) export class DisplayBillingAddressComponent { - @Input({ required: true }) owner!: BillableEntity; + @Input({ required: true }) subscriber!: BitwardenSubscriber; @Input({ required: true }) billingAddress!: BillingAddress | null; + @Input() taxIdWarning?: TaxIdWarningType; @Output() updated = new EventEmitter(); constructor(private dialogService: DialogService) {} @@ -42,8 +58,9 @@ export class DisplayBillingAddressComponent { editBillingAddress = async (): Promise => { const dialogRef = EditBillingAddressDialogComponent.open(this.dialogService, { data: { - owner: this.owner, + subscriber: this.subscriber, billingAddress: this.billingAddress, + taxIdWarning: this.taxIdWarning, }, }); @@ -53,4 +70,22 @@ export class DisplayBillingAddressComponent { this.updated.emit(result.billingAddress); } }; + + get showMissingTaxIdBadge(): boolean { + return this.subscriber.type !== "account" && this.taxIdWarning === TaxIdWarningTypes.Missing; + } + + get showTaxIdPendingVerificationBadge(): boolean { + return ( + this.subscriber.type !== "account" && + this.taxIdWarning === TaxIdWarningTypes.PendingVerification + ); + } + + get showUnverifiedTaxIdBadge(): boolean { + return ( + this.subscriber.type !== "account" && + this.taxIdWarning === TaxIdWarningTypes.FailedVerification + ); + } } diff --git a/apps/web/src/app/billing/payment/components/display-payment-method.component.ts b/apps/web/src/app/billing/payment/components/display-payment-method.component.ts index 769472bcfcf..df42d04b802 100644 --- a/apps/web/src/app/billing/payment/components/display-payment-method.component.ts +++ b/apps/web/src/app/billing/payment/components/display-payment-method.component.ts @@ -4,7 +4,7 @@ import { lastValueFrom } from "rxjs"; import { DialogService } from "@bitwarden/components"; import { SharedModule } from "../../../shared"; -import { BillableEntity } from "../../types"; +import { BitwardenSubscriber } from "../../types"; import { MaskedPaymentMethod } from "../types"; import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dialog.component"; @@ -19,7 +19,10 @@ import { VerifyBankAccountComponent } from "./verify-bank-account.component"; @switch (paymentMethod.type) { @case ("bankAccount") { @if (!paymentMethod.verified) { - + } @@ -63,7 +66,7 @@ import { VerifyBankAccountComponent } from "./verify-bank-account.component"; imports: [SharedModule, VerifyBankAccountComponent], }) export class DisplayPaymentMethodComponent { - @Input({ required: true }) owner!: BillableEntity; + @Input({ required: true }) subscriber!: BitwardenSubscriber; @Input({ required: true }) paymentMethod!: MaskedPaymentMethod | null; @Output() updated = new EventEmitter(); @@ -82,7 +85,7 @@ export class DisplayPaymentMethodComponent { changePaymentMethod = async (): Promise => { const dialogRef = ChangePaymentMethodDialogComponent.open(this.dialogService, { data: { - owner: this.owner, + subscriber: this.subscriber, }, }); diff --git a/apps/web/src/app/billing/payment/components/edit-billing-address-dialog.component.ts b/apps/web/src/app/billing/payment/components/edit-billing-address-dialog.component.ts index c844d08df58..de2f2f94497 100644 --- a/apps/web/src/app/billing/payment/components/edit-billing-address-dialog.component.ts +++ b/apps/web/src/app/billing/payment/components/edit-billing-address-dialog.component.ts @@ -3,18 +3,31 @@ import { Component, Inject } from "@angular/core"; import { ProductTierType } from "@bitwarden/common/billing/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components"; - -import { SharedModule } from "../../../shared"; -import { BillingClient } from "../../services"; -import { BillableEntity } from "../../types"; -import { BillingAddress, getTaxIdTypeForCountry } from "../types"; +import { + CalloutTypes, + DialogConfig, + DialogRef, + DialogService, + ToastService, +} from "@bitwarden/components"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; +import { + BillingAddress, + getTaxIdTypeForCountry, +} from "@bitwarden/web-vault/app/billing/payment/types"; +import { BitwardenSubscriber } from "@bitwarden/web-vault/app/billing/types"; +import { + TaxIdWarningType, + TaxIdWarningTypes, +} from "@bitwarden/web-vault/app/billing/warnings/types"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { EnterBillingAddressComponent } from "./enter-billing-address.component"; type DialogParams = { - owner: BillableEntity; + subscriber: BitwardenSubscriber; billingAddress: BillingAddress | null; + taxIdWarning?: TaxIdWarningType; }; type DialogResult = @@ -30,11 +43,18 @@ type DialogResult = {{ "editBillingAddress" | i18n }}
+ @let callout = taxIdWarningCallout; + @if (callout) { + + {{ callout.message }} + + } @@ -57,13 +77,13 @@ type DialogResult = `, standalone: true, imports: [EnterBillingAddressComponent, SharedModule], - providers: [BillingClient], + providers: [SubscriberBillingClient], }) export class EditBillingAddressDialogComponent { protected formGroup = EnterBillingAddressComponent.getFormGroup(); constructor( - private billingClient: BillingClient, + private billingClient: SubscriberBillingClient, @Inject(DIALOG_DATA) protected dialogParams: DialogParams, private dialogRef: DialogRef, private i18nService: I18nService, @@ -93,7 +113,7 @@ export class EditBillingAddressDialogComponent { : { ...addressFields, taxId: null }; const result = await this.billingClient.updateBillingAddress( - this.dialogParams.owner, + this.dialogParams.subscriber, billingAddress, ); @@ -125,7 +145,7 @@ export class EditBillingAddressDialogComponent { }; get supportsTaxId(): boolean { - switch (this.dialogParams.owner.type) { + switch (this.dialogParams.subscriber.type) { case "account": { return false; } @@ -134,7 +154,7 @@ export class EditBillingAddressDialogComponent { ProductTierType.TeamsStarter, ProductTierType.Teams, ProductTierType.Enterprise, - ].includes(this.dialogParams.owner.data.productTierType); + ].includes(this.dialogParams.subscriber.data.productTierType); } case "provider": { return true; @@ -142,6 +162,37 @@ export class EditBillingAddressDialogComponent { } } + get taxIdWarningCallout(): { + type: CalloutTypes; + title: string; + message: string; + } | null { + if ( + !this.supportsTaxId || + !this.dialogParams.taxIdWarning || + this.dialogParams.taxIdWarning === TaxIdWarningTypes.PendingVerification + ) { + return null; + } + + switch (this.dialogParams.taxIdWarning) { + case TaxIdWarningTypes.Missing: { + return { + type: "warning", + title: this.i18nService.t("missingTaxIdCalloutTitle"), + message: this.i18nService.t("missingTaxIdCalloutDescription"), + }; + } + case TaxIdWarningTypes.FailedVerification: { + return { + type: "warning", + title: this.i18nService.t("unverifiedTaxIdCalloutTitle"), + message: this.i18nService.t("unverifiedTaxIdCalloutDescription"), + }; + } + } + } + static open = (dialogService: DialogService, dialogConfig: DialogConfig) => dialogService.open(EditBillingAddressDialogComponent, dialogConfig); } diff --git a/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts b/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts index ab59e965b4e..7659b7ed5ca 100644 --- a/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts +++ b/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts @@ -3,9 +3,14 @@ import { FormControl, FormGroup, Validators } from "@angular/forms"; import { map, Observable, startWith, Subject, takeUntil } from "rxjs"; import { ControlsOf } from "@bitwarden/angular/types/controls-of"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + TaxIdWarningType, + TaxIdWarningTypes, +} from "@bitwarden/web-vault/app/billing/warnings/types"; import { SharedModule } from "../../../shared"; -import { BillingAddress, selectableCountries, taxIdTypes } from "../types"; +import { BillingAddress, getTaxIdTypeForCountry, selectableCountries, taxIdTypes } from "../types"; export interface BillingAddressControls { country: string; @@ -28,6 +33,7 @@ type Scenario = type: "update"; existing?: BillingAddress; supportsTaxId: boolean; + taxIdWarning?: TaxIdWarningType; }; @Component({ @@ -110,7 +116,7 @@ type Scenario =
@if (supportsTaxId$ | async) { -
+
{{ "taxIdNumber" | i18n }} + @let hint = taxIdWarningHint; + @if (hint) { + {{ hint }} + }
} @@ -137,6 +154,8 @@ export class EnterBillingAddressComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); + constructor(private i18nService: I18nService) {} + ngOnInit() { switch (this.scenario.type) { case "checkout": { @@ -185,6 +204,40 @@ export class EnterBillingAddressComponent implements OnInit, OnDestroy { this.group.controls.state.disable(); }; + get taxIdWarningHint() { + if ( + this.scenario.type === "checkout" || + !this.scenario.supportsTaxId || + !this.group.value.country || + this.scenario.taxIdWarning !== TaxIdWarningTypes.FailedVerification + ) { + return null; + } + + const taxIdType = getTaxIdTypeForCountry(this.group.value.country); + + if (!taxIdType) { + return null; + } + + const checkInputFormat = this.i18nService.t("checkInputFormat"); + + switch (taxIdType.code) { + case "au_abn": { + const exampleFormat = this.i18nService.t("exampleTaxIdFormat", "ABN", taxIdType.example); + return `${checkInputFormat} ${exampleFormat}`; + } + case "eu_vat": { + const exampleFormat = this.i18nService.t("exampleTaxIdFormat", "EU VAT", taxIdType.example); + return `${checkInputFormat} ${exampleFormat}`; + } + case "gb_vat": { + const exampleFormat = this.i18nService.t("exampleTaxIdFormat", "GB VAT", taxIdType.example); + return `${checkInputFormat} ${exampleFormat}`; + } + } + } + static getFormGroup = (): BillingAddressFormGroup => new FormGroup({ country: new FormControl("", { diff --git a/apps/web/src/app/billing/payment/components/require-payment-method-dialog.component.ts b/apps/web/src/app/billing/payment/components/require-payment-method-dialog.component.ts index 72585badca0..b1ca1922775 100644 --- a/apps/web/src/app/billing/payment/components/require-payment-method-dialog.component.ts +++ b/apps/web/src/app/billing/payment/components/require-payment-method-dialog.component.ts @@ -9,10 +9,10 @@ import { DialogService, ToastService, } from "@bitwarden/components"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; import { SharedModule } from "../../../shared"; -import { BillingClient } from "../../services"; -import { BillableEntity } from "../../types"; +import { BitwardenSubscriber } from "../../types"; import { EnterPaymentMethodComponent } from "./enter-payment-method.component"; import { @@ -21,7 +21,7 @@ import { } from "./submit-payment-method-dialog.component"; type DialogParams = { - owner: BillableEntity; + subscriber: BitwardenSubscriber; callout: { type: CalloutTypes; title: string; @@ -53,20 +53,20 @@ type DialogParams = { `, standalone: true, imports: [EnterPaymentMethodComponent, SharedModule], - providers: [BillingClient], + providers: [SubscriberBillingClient], }) export class RequirePaymentMethodDialogComponent extends SubmitPaymentMethodDialogComponent { - protected override owner: BillableEntity; + protected override subscriber: BitwardenSubscriber; constructor( - billingClient: BillingClient, + billingClient: SubscriberBillingClient, @Inject(DIALOG_DATA) protected dialogParams: DialogParams, dialogRef: DialogRef, i18nService: I18nService, toastService: ToastService, ) { super(billingClient, dialogRef, i18nService, toastService); - this.owner = this.dialogParams.owner; + this.subscriber = this.dialogParams.subscriber; } static open = (dialogService: DialogService, dialogConfig: DialogConfig) => diff --git a/apps/web/src/app/billing/payment/components/submit-payment-method-dialog.component.ts b/apps/web/src/app/billing/payment/components/submit-payment-method-dialog.component.ts index 0a0a5bf26d9..62d2b775eb5 100644 --- a/apps/web/src/app/billing/payment/components/submit-payment-method-dialog.component.ts +++ b/apps/web/src/app/billing/payment/components/submit-payment-method-dialog.component.ts @@ -2,9 +2,9 @@ import { Component, ViewChild } from "@angular/core"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { DialogRef, ToastService } from "@bitwarden/components"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; -import { BillingClient } from "../../services"; -import { BillableEntity } from "../../types"; +import { BitwardenSubscriber } from "../../types"; import { MaskedPaymentMethod } from "../types"; import { EnterPaymentMethodComponent } from "./enter-payment-method.component"; @@ -20,10 +20,10 @@ export abstract class SubmitPaymentMethodDialogComponent { private enterPaymentMethodComponent!: EnterPaymentMethodComponent; protected formGroup = EnterPaymentMethodComponent.getFormGroup(); - protected abstract owner: BillableEntity; + protected abstract subscriber: BitwardenSubscriber; protected constructor( - protected billingClient: BillingClient, + protected billingClient: SubscriberBillingClient, protected dialogRef: DialogRef, protected i18nService: I18nService, protected toastService: ToastService, @@ -43,7 +43,7 @@ export abstract class SubmitPaymentMethodDialogComponent { : null; const result = await this.billingClient.updatePaymentMethod( - this.owner, + this.subscriber, paymentMethod, billingAddress, ); diff --git a/apps/web/src/app/billing/payment/components/verify-bank-account.component.ts b/apps/web/src/app/billing/payment/components/verify-bank-account.component.ts index f79e9a1b5fc..b1a2814daf2 100644 --- a/apps/web/src/app/billing/payment/components/verify-bank-account.component.ts +++ b/apps/web/src/app/billing/payment/components/verify-bank-account.component.ts @@ -3,10 +3,10 @@ import { FormControl, FormGroup, Validators } from "@angular/forms"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ToastService } from "@bitwarden/components"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; import { SharedModule } from "../../../shared"; -import { BillingClient } from "../../services"; -import { BillableEntity } from "../../types"; +import { BitwardenSubscriber } from "../../types"; import { MaskedPaymentMethod } from "../types"; @Component({ @@ -32,10 +32,10 @@ import { MaskedPaymentMethod } from "../types"; `, standalone: true, imports: [SharedModule], - providers: [BillingClient], + providers: [SubscriberBillingClient], }) export class VerifyBankAccountComponent { - @Input({ required: true }) owner!: BillableEntity; + @Input({ required: true }) subscriber!: BitwardenSubscriber; @Output() verified = new EventEmitter(); protected formGroup = new FormGroup({ @@ -47,7 +47,7 @@ export class VerifyBankAccountComponent { }); constructor( - private billingClient: BillingClient, + private billingClient: SubscriberBillingClient, private i18nService: I18nService, private toastService: ToastService, ) {} @@ -60,7 +60,7 @@ export class VerifyBankAccountComponent { } const result = await this.billingClient.verifyBankAccount( - this.owner, + this.subscriber, this.formGroup.value.descriptorCode!, ); diff --git a/apps/web/src/app/billing/services/index.ts b/apps/web/src/app/billing/services/index.ts index dcd2c05034a..e291ca6a454 100644 --- a/apps/web/src/app/billing/services/index.ts +++ b/apps/web/src/app/billing/services/index.ts @@ -1,4 +1,3 @@ -export * from "./billing.client"; export * from "./billing-services.module"; export * from "./braintree.service"; export * from "./stripe.service"; diff --git a/apps/web/src/app/billing/settings/sponsoring-org-row.component.html b/apps/web/src/app/billing/settings/sponsoring-org-row.component.html index 1e5690cd85a..5167c0a5c32 100644 --- a/apps/web/src/app/billing/settings/sponsoring-org-row.component.html +++ b/apps/web/src/app/billing/settings/sponsoring-org-row.component.html @@ -12,7 +12,7 @@ bitIconButton="bwi-ellipsis-v" buttonType="main" [bitMenuTriggerFor]="appListDropdown" - appA11yTitle="{{ 'options' | i18n }}" + label="{{ 'options' | i18n }}" >

diff --git a/apps/web/src/app/billing/types/billable-entity.ts b/apps/web/src/app/billing/types/bitwarden-subscriber.ts similarity index 67% rename from apps/web/src/app/billing/types/billable-entity.ts rename to apps/web/src/app/billing/types/bitwarden-subscriber.ts index 79ed12a4161..3454d6a9651 100644 --- a/apps/web/src/app/billing/types/billable-entity.ts +++ b/apps/web/src/app/billing/types/bitwarden-subscriber.ts @@ -4,12 +4,14 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; -export type BillableEntity = +export type BitwardenSubscriber = | { type: "account"; data: Account } | { type: "organization"; data: Organization } | { type: "provider"; data: Provider }; -export const accountToBillableEntity = map((account) => { +export type NonIndividualSubscriber = Exclude; + +export const mapAccountToSubscriber = map((account) => { if (!account) { throw new Error("Account not found"); } @@ -19,7 +21,7 @@ export const accountToBillableEntity = map((acco }; }); -export const organizationToBillableEntity = map( +export const mapOrganizationToSubscriber = map( (organization) => { if (!organization) { throw new Error("Organization not found"); @@ -31,7 +33,7 @@ export const organizationToBillableEntity = map((provider) => { +export const mapProviderToSubscriber = map((provider) => { if (!provider) { throw new Error("Organization not found"); } diff --git a/apps/web/src/app/billing/types/index.ts b/apps/web/src/app/billing/types/index.ts index 1278e0f2e14..50c007677f3 100644 --- a/apps/web/src/app/billing/types/index.ts +++ b/apps/web/src/app/billing/types/index.ts @@ -1,2 +1,2 @@ -export * from "./billable-entity"; +export * from "./bitwarden-subscriber"; export * from "./free-trial"; diff --git a/apps/web/src/app/billing/warnings/components/index.ts b/apps/web/src/app/billing/warnings/components/index.ts index 1e1e0682e62..5edefadb1ee 100644 --- a/apps/web/src/app/billing/warnings/components/index.ts +++ b/apps/web/src/app/billing/warnings/components/index.ts @@ -1,2 +1 @@ -export * from "./organization-free-trial-warning.component"; -export * from "./organization-reseller-renewal-warning.component"; +export * from "./tax-id-warning.component"; diff --git a/apps/web/src/app/billing/warnings/components/tax-id-warning.component.ts b/apps/web/src/app/billing/warnings/components/tax-id-warning.component.ts new file mode 100644 index 00000000000..7527ef8f0b7 --- /dev/null +++ b/apps/web/src/app/billing/warnings/components/tax-id-warning.component.ts @@ -0,0 +1,286 @@ +import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { + BehaviorSubject, + combineLatest, + filter, + firstValueFrom, + lastValueFrom, + map, + Observable, + switchMap, +} from "rxjs"; + +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { BannerModule, DialogService } from "@bitwarden/components"; +import { BILLING_DISK, StateProvider, UserKeyDefinition } from "@bitwarden/state"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; +import { EditBillingAddressDialogComponent } from "@bitwarden/web-vault/app/billing/payment/components"; +import { NonIndividualSubscriber } from "@bitwarden/web-vault/app/billing/types"; +import { + TaxIdWarningType, + TaxIdWarningTypes, +} from "@bitwarden/web-vault/app/billing/warnings/types"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +type DismissalCounts = { + [TaxIdWarningTypes.Missing]?: number; + [TaxIdWarningTypes.FailedVerification]?: number; +}; + +const DISMISSALS_COUNT_KEY = new UserKeyDefinition( + BILLING_DISK, + "taxIdWarningDismissalCounts", + { + deserializer: (dismissalCounts) => dismissalCounts, + clearOn: [], + }, +); + +type DismissedThisSession = { + [TaxIdWarningTypes.Missing]?: boolean; + [TaxIdWarningTypes.FailedVerification]?: boolean; +}; + +const DISMISSED_THIS_SESSION_KEY = new UserKeyDefinition( + BILLING_DISK, + "taxIdWarningDismissedThisSession", + { + deserializer: (dismissedThisSession) => dismissedThisSession, + clearOn: ["logout"], + }, +); + +type Dismissals = { + [TaxIdWarningTypes.Missing]: { + count: number; + dismissedThisSession: boolean; + }; + [TaxIdWarningTypes.FailedVerification]: { + count: number; + dismissedThisSession: boolean; + }; +}; + +const shouldShowWarning = ( + warning: Exclude, + dismissals: Dismissals, +) => { + const dismissalsForType = dismissals[warning]; + if (dismissalsForType.dismissedThisSession) { + return false; + } + return dismissalsForType.count < 3; +}; + +type View = { + message: string; + callToAction: string; +}; + +type GetWarning$ = () => Observable; + +@Component({ + selector: "app-tax-id-warning", + template: ` + @if (enableTaxIdWarning$ | async) { + @let view = view$ | async; + + @if (view) { + + {{ view.message }} + + {{ view.callToAction }} + + + } + } + `, + imports: [BannerModule, SharedModule], +}) +export class TaxIdWarningComponent implements OnInit { + @Input({ required: true }) subscriber!: NonIndividualSubscriber; + @Input({ required: true }) getWarning$!: GetWarning$; + @Output() billingAddressUpdated = new EventEmitter(); + + protected enableTaxIdWarning$ = this.configService.getFeatureFlag$( + FeatureFlag.PM22415_TaxIDWarnings, + ); + + protected userId$ = this.accountService.activeAccount$.pipe( + filter((account): account is Account => account !== null), + getUserId, + ); + + protected dismissals$: Observable = this.userId$.pipe( + switchMap((userId) => + combineLatest([ + this.stateProvider.getUser(userId, DISMISSALS_COUNT_KEY).state$.pipe( + map((dismissalCounts) => { + if (!dismissalCounts) { + return { + [TaxIdWarningTypes.Missing]: 0, + [TaxIdWarningTypes.FailedVerification]: 0, + }; + } + return { + [TaxIdWarningTypes.Missing]: dismissalCounts[TaxIdWarningTypes.Missing] ?? 0, + [TaxIdWarningTypes.FailedVerification]: + dismissalCounts[TaxIdWarningTypes.FailedVerification] ?? 0, + }; + }), + ), + this.stateProvider.getUser(userId, DISMISSED_THIS_SESSION_KEY).state$.pipe( + map((dismissedThisSession) => { + if (!dismissedThisSession) { + return { + [TaxIdWarningTypes.Missing]: false, + [TaxIdWarningTypes.FailedVerification]: false, + }; + } + return { + [TaxIdWarningTypes.Missing]: dismissedThisSession[TaxIdWarningTypes.Missing] ?? false, + [TaxIdWarningTypes.FailedVerification]: + dismissedThisSession[TaxIdWarningTypes.FailedVerification] ?? false, + }; + }), + ), + ]), + ), + map(([dismissalCounts, dismissedThisSession]) => ({ + [TaxIdWarningTypes.Missing]: { + count: dismissalCounts[TaxIdWarningTypes.Missing], + dismissedThisSession: dismissedThisSession[TaxIdWarningTypes.Missing], + }, + [TaxIdWarningTypes.FailedVerification]: { + count: dismissalCounts[TaxIdWarningTypes.FailedVerification], + dismissedThisSession: dismissedThisSession[TaxIdWarningTypes.FailedVerification], + }, + })), + ); + + protected getWarningSubject = new BehaviorSubject(null); + + protected warning$ = this.getWarningSubject.pipe(switchMap(() => this.getWarning$())); + + protected view$: Observable = combineLatest([this.warning$, this.dismissals$]).pipe( + map(([warning, dismissals]) => { + if (!warning || warning === TaxIdWarningTypes.PendingVerification) { + return null; + } + + if (!shouldShowWarning(warning, dismissals)) { + return null; + } + + switch (warning) { + case TaxIdWarningTypes.Missing: { + return { + message: this.i18nService.t("missingTaxIdWarning"), + callToAction: this.i18nService.t("addTaxId"), + }; + } + case TaxIdWarningTypes.FailedVerification: { + return { + message: this.i18nService.t("unverifiedTaxIdWarning"), + callToAction: this.i18nService.t("editTaxId"), + }; + } + } + }), + ); + + constructor( + private accountService: AccountService, + private configService: ConfigService, + private dialogService: DialogService, + private i18nService: I18nService, + private subscriberBillingClient: SubscriberBillingClient, + private stateProvider: StateProvider, + ) {} + + ngOnInit() { + this.getWarningSubject.next(this.getWarning$); + } + + editBillingAddress = async () => { + const billingAddress = await this.subscriberBillingClient.getBillingAddress(this.subscriber); + const warning = (await firstValueFrom(this.warning$)) ?? undefined; + + const dialogRef = EditBillingAddressDialogComponent.open(this.dialogService, { + data: { + subscriber: this.subscriber, + billingAddress, + taxIdWarning: warning, + }, + }); + + const result = await lastValueFrom(dialogRef.closed); + + if (result?.type === "success") { + this.billingAddressUpdated.emit(); + } + }; + + trackDismissal = async () => { + const warning = await firstValueFrom(this.warning$); + if (!warning || warning === TaxIdWarningTypes.PendingVerification) { + return; + } + const userId = await firstValueFrom(this.userId$); + const updateDismissalCounts = this.stateProvider + .getUser(userId, DISMISSALS_COUNT_KEY) + .update((dismissalCounts) => { + if (!dismissalCounts) { + return { + [warning]: 1, + }; + } + const dismissalsByType = dismissalCounts[warning]; + if (!dismissalsByType) { + return { + ...dismissalCounts, + [warning]: 1, + }; + } + return { + ...dismissalCounts, + [warning]: dismissalsByType + 1, + }; + }); + const updateDismissedThisSession = this.stateProvider + .getUser(userId, DISMISSED_THIS_SESSION_KEY) + .update((dismissedThisSession) => { + if (!dismissedThisSession) { + return { + [warning]: true, + }; + } + const dismissedThisSessionByType = dismissedThisSession[warning]; + if (!dismissedThisSessionByType) { + return { + ...dismissedThisSession, + }; + } + return { + ...dismissedThisSession, + [warning]: dismissedThisSessionByType, + }; + }); + await Promise.all([updateDismissalCounts, updateDismissedThisSession]); + }; +} diff --git a/apps/web/src/app/billing/warnings/services/organization-warnings.service.spec.ts b/apps/web/src/app/billing/warnings/services/organization-warnings.service.spec.ts deleted file mode 100644 index c75dde0c9e5..00000000000 --- a/apps/web/src/app/billing/warnings/services/organization-warnings.service.spec.ts +++ /dev/null @@ -1,358 +0,0 @@ -import { Router } from "@angular/router"; -import { mock, MockProxy } from "jest-mock-extended"; -import { firstValueFrom, lastValueFrom } from "rxjs"; - -import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction"; -import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/response/organization-warnings.response"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { DialogService, SimpleDialogOptions } from "@bitwarden/components"; - -import { OrganizationWarningsService } from "./organization-warnings.service"; - -// Skipped since Angular complains about `TypeError: Cannot read properties of undefined (reading 'ngModule')` -// which is typically a sign of circular dependencies. The problem seems to be originating from `ChangePlanDialogComponent`. -describe.skip("OrganizationWarningsService", () => { - let dialogService: MockProxy; - let i18nService: MockProxy; - let organizationApiService: MockProxy; - let organizationBillingApiService: MockProxy; - let router: MockProxy; - - let organizationWarningsService: OrganizationWarningsService; - - const respond = (responseBody: any) => - Promise.resolve(new OrganizationWarningsResponse(responseBody)); - - const empty = () => Promise.resolve(new OrganizationWarningsResponse({})); - - beforeEach(() => { - dialogService = mock(); - i18nService = mock(); - organizationApiService = mock(); - organizationBillingApiService = mock(); - router = mock(); - - organizationWarningsService = new OrganizationWarningsService( - dialogService, - i18nService, - organizationApiService, - organizationBillingApiService, - router, - ); - }); - - describe("cache$", () => { - it("should only request warnings once for a specific organization and replay the cached result for multiple subscriptions", async () => { - const response1 = respond({ - freeTrial: { - remainingTrialDays: 1, - }, - }); - - const organization1 = { - id: "1", - name: "Test", - } as Organization; - - const response2 = respond({ - freeTrial: { - remainingTrialDays: 2, - }, - }); - - const organization2 = { - id: "2", - name: "Test", - } as Organization; - - organizationBillingApiService.getWarnings.mockImplementation((id) => { - if (id === organization1.id) { - return response1; - } - - if (id === organization2.id) { - return response2; - } - - return empty(); - }); - - const oneDayRemainingTranslation = "oneDayRemaining"; - const twoDaysRemainingTranslation = "twoDaysRemaining"; - - i18nService.t.mockImplementation((id, p1) => { - if (id === "freeTrialEndPromptTomorrowNoOrgName") { - return oneDayRemainingTranslation; - } - - if (id === "freeTrialEndPromptCount" && p1 === 2) { - return twoDaysRemainingTranslation; - } - - return ""; - }); - - const organization1Subscription1 = await firstValueFrom( - organizationWarningsService.getFreeTrialWarning$(organization1), - ); - - const organization1Subscription2 = await firstValueFrom( - organizationWarningsService.getFreeTrialWarning$(organization1), - ); - - expect(organization1Subscription1).toEqual({ - organization: organization1, - message: oneDayRemainingTranslation, - }); - - expect(organization1Subscription2).toEqual(organization1Subscription1); - - const organization2Subscription1 = await firstValueFrom( - organizationWarningsService.getFreeTrialWarning$(organization2), - ); - - const organization2Subscription2 = await firstValueFrom( - organizationWarningsService.getFreeTrialWarning$(organization2), - ); - - expect(organization2Subscription1).toEqual({ - organization: organization2, - message: twoDaysRemainingTranslation, - }); - - expect(organization2Subscription2).toEqual(organization2Subscription1); - - expect(organizationBillingApiService.getWarnings).toHaveBeenCalledTimes(2); - }); - }); - - describe("getFreeTrialWarning$", () => { - it("should not emit a free trial warning when none is included in the warnings response", (done) => { - const organization = { - id: "1", - name: "Test", - } as Organization; - - organizationBillingApiService.getWarnings.mockReturnValue(empty()); - - const warning$ = organizationWarningsService.getFreeTrialWarning$(organization); - - warning$.subscribe({ - next: () => { - fail("Observable should not emit a value."); - }, - complete: () => { - done(); - }, - }); - }); - - it("should emit a free trial warning when one is included in the warnings response", async () => { - const response = respond({ - freeTrial: { - remainingTrialDays: 1, - }, - }); - - const organization = { - id: "1", - name: "Test", - } as Organization; - - organizationBillingApiService.getWarnings.mockImplementation((id) => { - if (id === organization.id) { - return response; - } else { - return empty(); - } - }); - - const translation = "translation"; - i18nService.t.mockImplementation((id) => { - if (id === "freeTrialEndPromptTomorrowNoOrgName") { - return translation; - } else { - return ""; - } - }); - - const warning = await firstValueFrom( - organizationWarningsService.getFreeTrialWarning$(organization), - ); - - expect(warning).toEqual({ - organization, - message: translation, - }); - }); - }); - - describe("getResellerRenewalWarning$", () => { - it("should not emit a reseller renewal warning when none is included in the warnings response", (done) => { - const organization = { - id: "1", - name: "Test", - } as Organization; - - organizationBillingApiService.getWarnings.mockReturnValue(empty()); - - const warning$ = organizationWarningsService.getResellerRenewalWarning$(organization); - - warning$.subscribe({ - next: () => { - fail("Observable should not emit a value."); - }, - complete: () => { - done(); - }, - }); - }); - - it("should emit a reseller renewal warning when one is included in the warnings response", async () => { - const response = respond({ - resellerRenewal: { - type: "upcoming", - upcoming: { - renewalDate: "2026-01-01T00:00:00.000Z", - }, - }, - }); - - const organization = { - id: "1", - name: "Test", - providerName: "Provider", - } as Organization; - - organizationBillingApiService.getWarnings.mockImplementation((id) => { - if (id === organization.id) { - return response; - } else { - return empty(); - } - }); - - const formattedDate = new Date("2026-01-01T00:00:00.000Z").toLocaleDateString("en-US", { - month: "short", - day: "2-digit", - year: "numeric", - }); - - const translation = "translation"; - i18nService.t.mockImplementation((id, p1, p2) => { - if ( - id === "resellerRenewalWarningMsg" && - p1 === organization.providerName && - p2 === formattedDate - ) { - return translation; - } else { - return ""; - } - }); - - const warning = await firstValueFrom( - organizationWarningsService.getResellerRenewalWarning$(organization), - ); - - expect(warning).toEqual({ - type: "info", - message: translation, - }); - }); - }); - - describe("showInactiveSubscriptionDialog$", () => { - it("should not emit the opening of a dialog for an inactive subscription warning when the warning is not included in the warnings response", (done) => { - const organization = { - id: "1", - name: "Test", - } as Organization; - - organizationBillingApiService.getWarnings.mockReturnValue(empty()); - - const warning$ = organizationWarningsService.showInactiveSubscriptionDialog$(organization); - - warning$.subscribe({ - next: () => { - fail("Observable should not emit a value."); - }, - complete: () => { - done(); - }, - }); - }); - - it("should emit the opening of a dialog for an inactive subscription warning when the warning is included in the warnings response", async () => { - const response = respond({ - inactiveSubscription: { - resolution: "add_payment_method", - }, - }); - - const organization = { - id: "1", - name: "Test", - providerName: "Provider", - } as Organization; - - organizationBillingApiService.getWarnings.mockImplementation((id) => { - if (id === organization.id) { - return response; - } else { - return empty(); - } - }); - - const titleTranslation = "title"; - const continueTranslation = "continue"; - const closeTranslation = "close"; - - i18nService.t.mockImplementation((id, param) => { - if (id === "suspendedOrganizationTitle" && param === organization.name) { - return titleTranslation; - } - if (id === "continue") { - return continueTranslation; - } - if (id === "close") { - return closeTranslation; - } - return ""; - }); - - const expectedOptions = { - title: titleTranslation, - content: { - key: "suspendedOwnerOrgMessage", - }, - type: "danger", - acceptButtonText: continueTranslation, - cancelButtonText: closeTranslation, - } as SimpleDialogOptions; - - dialogService.openSimpleDialog.mockImplementation((options) => { - if (JSON.stringify(options) == JSON.stringify(expectedOptions)) { - return Promise.resolve(true); - } else { - return Promise.resolve(false); - } - }); - - const observable$ = organizationWarningsService.showInactiveSubscriptionDialog$(organization); - - const routerNavigateSpy = jest.spyOn(router, "navigate").mockResolvedValue(true); - - await lastValueFrom(observable$); - - expect(routerNavigateSpy).toHaveBeenCalledWith( - ["organizations", `${organization.id}`, "billing", "payment-method"], - { - state: { launchPaymentModalAutomatically: true }, - }, - ); - }); - }); -}); diff --git a/apps/web/src/app/billing/warnings/types/index.ts b/apps/web/src/app/billing/warnings/types/index.ts index fc0c7d278ed..1d7b17fcd28 100644 --- a/apps/web/src/app/billing/warnings/types/index.ts +++ b/apps/web/src/app/billing/warnings/types/index.ts @@ -1 +1 @@ -export * from "./organization-warnings"; +export * from "./tax-id-warning-type"; diff --git a/apps/web/src/app/billing/warnings/types/organization-warnings.ts b/apps/web/src/app/billing/warnings/types/organization-warnings.ts deleted file mode 100644 index 96bf5aff6f1..00000000000 --- a/apps/web/src/app/billing/warnings/types/organization-warnings.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; - -export type OrganizationFreeTrialWarning = { - organization: Pick; - message: string; -}; - -export type OrganizationResellerRenewalWarning = { - type: "info" | "warning"; - message: string; -}; diff --git a/apps/web/src/app/billing/warnings/types/tax-id-warning-type.ts b/apps/web/src/app/billing/warnings/types/tax-id-warning-type.ts new file mode 100644 index 00000000000..86bc76708aa --- /dev/null +++ b/apps/web/src/app/billing/warnings/types/tax-id-warning-type.ts @@ -0,0 +1,19 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +export const TaxIdWarningTypes = { + Missing: "tax_id_missing", + PendingVerification: "tax_id_pending_verification", + FailedVerification: "tax_id_failed_verification", +} as const; + +export type TaxIdWarningType = (typeof TaxIdWarningTypes)[keyof typeof TaxIdWarningTypes]; + +export class TaxIdWarningResponse extends BaseResponse { + type: TaxIdWarningType; + + constructor(response: any) { + super(response); + + this.type = this.getResponseProperty("Type"); + } +} diff --git a/apps/web/src/app/core/init.service.ts b/apps/web/src/app/core/init.service.ts index f4d05171d56..57d9918aad7 100644 --- a/apps/web/src/app/core/init.service.ts +++ b/apps/web/src/app/core/init.service.ts @@ -11,10 +11,10 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract import { DefaultVaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; -import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; import { IpcService } from "@bitwarden/common/platform/ipc"; import { NotificationsService } from "@bitwarden/common/platform/notifications"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; +import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; import { TaskService } from "@bitwarden/common/vault/tasks"; @@ -31,7 +31,6 @@ export class InitService { private i18nService: I18nServiceAbstraction, private eventUploadService: EventUploadServiceAbstraction, private twoFactorService: TwoFactorServiceAbstraction, - private stateService: StateServiceAbstraction, private keyService: KeyServiceAbstraction, private themingService: AbstractThemingService, private encryptService: EncryptService, @@ -41,13 +40,14 @@ export class InitService { private ipcService: IpcService, private sdkLoadService: SdkLoadService, private taskService: TaskService, + private readonly migrationRunner: MigrationRunner, @Inject(DOCUMENT) private document: Document, ) {} init() { return async () => { await this.sdkLoadService.loadAndInit(); - await this.stateService.init(); + await this.migrationRunner.run(); const activeAccount = await firstValueFrom(this.accountService.activeAccount$); if (activeAccount) { diff --git a/apps/web/src/app/dirt/reports/reports-layout.component.html b/apps/web/src/app/dirt/reports/reports-layout.component.html index 283d9213cc7..a27556a7aa9 100644 --- a/apps/web/src/app/dirt/reports/reports-layout.component.html +++ b/apps/web/src/app/dirt/reports/reports-layout.component.html @@ -2,7 +2,7 @@
diff --git a/apps/web/src/app/layouts/header/web-header.stories.ts b/apps/web/src/app/layouts/header/web-header.stories.ts index 9715dbf8cd3..7abddf01f2b 100644 --- a/apps/web/src/app/layouts/header/web-header.stories.ts +++ b/apps/web/src/app/layouts/header/web-header.stories.ts @@ -48,7 +48,7 @@ class MockStateService { @Component({ selector: "product-switcher", - template: ``, + template: ``, standalone: false, }) class MockProductSwitcher {} diff --git a/apps/web/src/app/layouts/product-switcher/product-switcher.component.html b/apps/web/src/app/layouts/product-switcher/product-switcher.component.html index f1942a02c20..a44f05a7ed7 100644 --- a/apps/web/src/app/layouts/product-switcher/product-switcher.component.html +++ b/apps/web/src/app/layouts/product-switcher/product-switcher.component.html @@ -3,7 +3,7 @@ bitIconButton="bwi bwi-fw bwi-filter" [bitMenuTriggerFor]="content?.menu" [buttonType]="buttonType" - [attr.aria-label]="'switchProducts' | i18n" + [label]="'switchProducts' | i18n" *ngIf="products$ | async" > diff --git a/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts b/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts index 0b7304a3657..cc919a929a9 100644 --- a/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts +++ b/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts @@ -179,7 +179,7 @@ type Story = StoryObj< const Template: Story = { render: (args) => ({ props: args, - template: ` + template: /*html*/ `
@@ -191,7 +191,7 @@ const Template: Story = {
- +
diff --git a/apps/web/src/app/settings/domain-rules.component.html b/apps/web/src/app/settings/domain-rules.component.html index 8ebeecb429f..880e2a6da0f 100644 --- a/apps/web/src/app/settings/domain-rules.component.html +++ b/apps/web/src/app/settings/domain-rules.component.html @@ -32,7 +32,7 @@ type="button" buttonType="danger" (click)="remove(i)" - appA11yTitle="{{ 'remove' | i18n }}" + label="{{ 'remove' | i18n }}" >
diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index 637e1b77ce0..d7dbdbc4ae5 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -8,10 +8,7 @@ import { import { LayoutComponent, NavigationModule } from "@bitwarden/components"; import { OrganizationLayoutComponent } from "../admin-console/organizations/layouts/organization-layout.component"; -import { EventsComponent as OrgEventsComponent } from "../admin-console/organizations/manage/events.component"; -import { UserConfirmComponent as OrgUserConfirmComponent } from "../admin-console/organizations/manage/user-confirm.component"; import { VerifyRecoverDeleteOrgComponent } from "../admin-console/organizations/manage/verify-recover-delete-org.component"; -import { AcceptFamilySponsorshipComponent } from "../admin-console/organizations/sponsorships/accept-family-sponsorship.component"; import { RecoverDeleteComponent } from "../auth/recover-delete.component"; import { RecoverTwoFactorComponent } from "../auth/recover-two-factor.component"; import { DangerZoneComponent } from "../auth/settings/account/danger-zone.component"; @@ -61,13 +58,10 @@ import { SharedModule } from "./shared.module"; PremiumBadgeComponent, ], declarations: [ - AcceptFamilySponsorshipComponent, - OrgEventsComponent, OrgExposedPasswordsReportComponent, OrgInactiveTwoFactorReportComponent, OrgReusedPasswordsReportComponent, OrgUnsecuredWebsitesReportComponent, - OrgUserConfirmComponent, OrgWeakPasswordsReportComponent, RecoverDeleteComponent, RecoverTwoFactorComponent, @@ -82,12 +76,10 @@ import { SharedModule } from "./shared.module"; UserVerificationModule, PremiumBadgeComponent, OrganizationLayoutComponent, - OrgEventsComponent, OrgExposedPasswordsReportComponent, OrgInactiveTwoFactorReportComponent, OrgReusedPasswordsReportComponent, OrgUnsecuredWebsitesReportComponent, - OrgUserConfirmComponent, OrgWeakPasswordsReportComponent, PremiumBadgeComponent, RecoverDeleteComponent, diff --git a/apps/web/src/app/tools/send/send.component.html b/apps/web/src/app/tools/send/send.component.html index 042046b85ff..b79f50311ed 100644 --- a/apps/web/src/app/tools/send/send.component.html +++ b/apps/web/src/app/tools/send/send.component.html @@ -156,7 +156,7 @@ type="button" [bitMenuTriggerFor]="sendOptions" bitIconButton="bwi-ellipsis-v" - appA11yTitle="{{ 'options' | i18n }}" + label="{{ 'options' | i18n }}" > @@ -101,7 +101,7 @@ bitIconButton="bwi-ellipsis-v" type="button" appStopProp - appA11yTitle="{{ 'options' | i18n }}" + label="{{ 'options' | i18n }}" > diff --git a/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.html index ad2886b1e59..7cd5129d3f0 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.html @@ -65,7 +65,7 @@ size="small" bitIconButton="bwi-ellipsis-v" type="button" - appA11yTitle="{{ 'options' | i18n }}" + label="{{ 'options' | i18n }}" appStopProp > } 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 ef928903a72..5ddccf6a395 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 @@ -56,7 +56,7 @@ bitIconButton="bwi-ellipsis-v" size="small" type="button" - appA11yTitle="{{ 'options' | i18n }}" + label="{{ 'options' | i18n }}" > @@ -67,8 +67,7 @@ bitIconButton="bwi-trash" buttonType="danger" size="default" - title="{{ 'delete' | i18n }}" - aria-label="Delete" + label="{{ 'delete' | i18n }}" [bitAction]="deleteDomain" type="submit" bitFormButton diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/scim.component.html b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/scim.component.html index 7ade2e6c63d..38cb077c623 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/scim.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/scim.component.html @@ -29,7 +29,7 @@ bitSuffix bitIconButton="bwi-clone" [bitAction]="copyScimUrl" - [appA11yTitle]="'copyScimUrl' | i18n" + [label]="'copyScimUrl' | i18n" > @@ -46,7 +46,7 @@ bitSuffix [bitIconButton]="showScimKey ? 'bwi-eye-slash' : 'bwi-eye'" [bitAction]="toggleScimKey" - [appA11yTitle]="'toggleVisibility' | i18n" + [label]="'toggleVisibility' | i18n" > {{ "scimApiKeyHelperText" | i18n }} diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/add-edit-member-dialog.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/add-edit-member-dialog.component.html index 60993f5570c..08e694aa45a 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/add-edit-member-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/add-edit-member-dialog.component.html @@ -58,7 +58,7 @@ bitIconButton="bwi-trash" buttonType="danger" bitFormButton - [appA11yTitle]="'delete' | i18n" + [label]="'delete' | i18n" [bitAction]="delete" [disabled]="loading" > diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html index f203b7a934a..07ccd997b96 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html @@ -79,7 +79,7 @@ type="button" bitIconButton="bwi-ellipsis-v" size="small" - appA11yTitle="{{ 'options' | i18n }}" + label="{{ 'options' | i18n }}" > @@ -193,7 +193,7 @@ bitSuffix type="button" [appCopyClick]="signedOutCallbackPath" - [appA11yTitle]="'copyValue' | i18n" + [label]="'copyValue' | i18n" > @@ -336,7 +336,7 @@ bitSuffix type="button" [appCopyClick]="spEntityId" - [appA11yTitle]="'copyValue' | i18n" + [label]="'copyValue' | i18n" > @@ -348,7 +348,7 @@ bitSuffix type="button" [appCopyClick]="spEntityIdStatic" - [appA11yTitle]="'copyValue' | i18n" + [label]="'copyValue' | i18n" > @@ -360,14 +360,14 @@ bitSuffix type="button" [appLaunchClick]="spMetadataUrl" - [appA11yTitle]="'launch' | i18n" + [label]="'launch' | i18n" > @@ -379,7 +379,7 @@ bitSuffix type="button" [appCopyClick]="spAcsUrl" - [appA11yTitle]="'copyValue' | i18n" + [label]="'copyValue' | i18n" > 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 043ce65b961..2ab82bd837b 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 @@ -86,7 +86,7 @@ type="button" bitIconButton="bwi-ellipsis-v" size="small" - appA11yTitle="{{ 'options' | i18n }}" + label="{{ 'options' | i18n }}" > diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/section.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/overview/section.component.html index ab7c67c7b22..e7f9692beb3 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/section.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/section.component.html @@ -7,6 +7,7 @@ (click)="toggle()" [attr.aria-expanded]="open" [attr.aria-controls]="contentId" + [label]="'toggleVisibility' | i18n" >
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.html index 24168d0b025..3a2c858ac31 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.html @@ -95,7 +95,7 @@ buttonType="danger" bitIconButton="bwi-trash" bitFormButton - [appA11yTitle]="'delete' | i18n" + [label]="'delete' | i18n" [bitAction]="delete" > diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.html index fbb0dd8888a..3399b550ba5 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.html @@ -40,8 +40,7 @@ bitIconButton="bwi-ellipsis-v" buttonType="main" [bitMenuTriggerFor]="tableMenu" - [title]="'options' | i18n" - [attr.aria-label]="'options' | i18n" + [label]="'options' | i18n" > @@ -65,8 +64,7 @@ type="button" bitIconButton="bwi-ellipsis-v" buttonType="main" - [title]="'options' | i18n" - [attr.aria-label]="'options' | i18n" + [label]="'options' | i18n" [bitMenuTriggerFor]="tokenMenu" > diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access.service.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access.service.ts index 6ef5be521ed..137eadb30b5 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access.service.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access.service.ts @@ -6,10 +6,10 @@ import { filter, firstValueFrom, map, Subject, switchMap } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; -import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { OrganizationId } from "@bitwarden/common/types/guid"; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/config/config.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/config/config.component.html index b17e47a39ec..11f8e0b9b77 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/config/config.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/config/config.component.html @@ -11,12 +11,19 @@ type="button" bitIconButton="bwi-clone" [bitAction]="copyIdentityUrl" + [label]="'copyCustomField' | i18n: identityUrl" > {{ "apiUrl" | i18n }} - +
@@ -27,6 +34,7 @@ type="button" bitIconButton="bwi-clone" [bitAction]="copyOrganizationId" + [label]="'copyCustomField' | i18n: organizationId" >
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.html index bfb7b985423..3d7fc9715c3 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.html @@ -39,8 +39,7 @@ type="button" bitIconButton="bwi-ellipsis-v" buttonType="main" - [title]="'options' | i18n" - [attr.aria-label]="'options' | i18n" + [label]="'options' | i18n" [bitMenuTriggerFor]="tableMenu" > @@ -72,8 +71,7 @@ type="button" bitIconButton="bwi-ellipsis-v" buttonType="main" - [title]="'options' | i18n" - [attr.aria-label]="'options' | i18n" + [label]="'options' | i18n" [bitMenuTriggerFor]="serviceAccountMenu" > diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.component.html index c8a50175781..d01faae4e6e 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.component.html @@ -67,8 +67,7 @@ buttonType="main" size="default" [disabled]="disabled" - [attr.title]="'remove' | i18n" - [attr.aria-label]="'remove' | i18n" + [label]="'remove' | i18n" (click)="selectionList.deselectItem(item.id); handleBlur()" > diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/projects-list.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/shared/projects-list.component.html index bc1655d8b28..236af0d414c 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/projects-list.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/projects-list.component.html @@ -43,8 +43,7 @@ bitIconButton="bwi-ellipsis-v" buttonType="main" [bitMenuTriggerFor]="tableMenu" - [title]="'options' | i18n" - [attr.aria-label]="'options' | i18n" + [label]="'options' | i18n" *ngIf="showMenus" > @@ -77,8 +76,7 @@ bitIconButton="bwi-clone" buttonType="main" size="small" - [title]="'copyUuid' | i18n" - [attr.aria-label]="'copyUuid' | i18n" + [label]="'copyUuid' | i18n" (click)="copyProjectUuidToClipboard(project.id)" >
@@ -94,8 +92,7 @@ bitIconButton="bwi-ellipsis-v" buttonType="main" [bitMenuTriggerFor]="projectMenu" - [title]="'options' | i18n" - [attr.aria-label]="'options' | i18n" + [label]="'options' | i18n" *ngIf="showMenus" > diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.html index 859c7417eb8..e5d22a01502 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.html @@ -45,8 +45,7 @@ type="button" bitIconButton="bwi-ellipsis-v" buttonType="main" - [title]="'options' | i18n" - [attr.aria-label]="'options' | i18n" + [label]="'options' | i18n" [bitMenuTriggerFor]="tableMenu" > @@ -78,8 +77,7 @@ bitIconButton="bwi-clone" buttonType="main" size="small" - [title]="'copyUuid' | i18n" - [attr.aria-label]="'copyUuid' | i18n" + [label]="'copyUuid' | i18n" (click)="copySecretUuidEvent.emit(secret.id)" > @@ -108,8 +106,7 @@ type="button" bitIconButton="bwi-ellipsis-v" buttonType="main" - [title]="'options' | i18n" - [attr.aria-label]="'options' | i18n" + [label]="'options' | i18n" [bitMenuTriggerFor]="secretMenu" > diff --git a/eslint.config.mjs b/eslint.config.mjs index 05a7d4b2394..577eacb92ff 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,7 @@ import angularRxjs from "eslint-plugin-rxjs-angular"; import storybook from "eslint-plugin-storybook"; import platformPlugins from "./libs/eslint/platform/index.mjs"; +import componentPlugins from "./libs/eslint/components/index.mjs"; export default tseslint.config( ...storybook.configs["flat/recommended"], @@ -174,6 +175,7 @@ export default tseslint.config( plugins: { "@angular-eslint/template": angular.templatePlugin, tailwindcss: eslintPluginTailwindCSS, + "@bitwarden/components": componentPlugins, }, rules: { "@angular-eslint/template/button-has-type": "error", @@ -193,6 +195,10 @@ export default tseslint.config( "tailwindcss/enforces-negative-arbitrary-values": "error", "tailwindcss/enforces-shorthand": "error", "tailwindcss/no-contradicting-classname": "error", + "@bitwarden/components/require-label-on-biticonbutton": [ + "error", + { ignoreIfHas: ["bitPasswordInputToggle"] }, + ], }, }, diff --git a/libs/angular/src/services/injection-tokens.ts b/libs/angular/src/services/injection-tokens.ts index 2122506890a..6bf3ab77252 100644 --- a/libs/angular/src/services/injection-tokens.ts +++ b/libs/angular/src/services/injection-tokens.ts @@ -13,7 +13,6 @@ import { ObservableStorageService, } from "@bitwarden/common/platform/abstractions/storage.service"; import { Theme } from "@bitwarden/common/platform/enums"; -import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { Message } from "@bitwarden/common/platform/messaging"; import { HttpOperations } from "@bitwarden/common/services/api.service"; import { SafeInjectionToken } from "@bitwarden/ui-common"; @@ -33,7 +32,6 @@ export const OBSERVABLE_DISK_LOCAL_STORAGE = new SafeInjectionToken< >("OBSERVABLE_DISK_LOCAL_STORAGE"); export const MEMORY_STORAGE = new SafeInjectionToken("MEMORY_STORAGE"); export const SECURE_STORAGE = new SafeInjectionToken("SECURE_STORAGE"); -export const STATE_FACTORY = new SafeInjectionToken("STATE_FACTORY"); export const LOGOUT_CALLBACK = new SafeInjectionToken< (logoutReason: LogoutReason, userId?: string) => Promise >("LOGOUT_CALLBACK"); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 6a145fb3210..d6e4e901b50 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -149,6 +149,10 @@ import { OrganizationBillingApiService } from "@bitwarden/common/billing/service import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.service"; import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service"; import { TaxService } from "@bitwarden/common/billing/services/tax.service"; +import { + DefaultKeyGenerationService, + KeyGenerationService, +} from "@bitwarden/common/key-management/crypto"; import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/encrypt.service.implementation"; @@ -184,7 +188,6 @@ import { } from "@bitwarden/common/platform/abstractions/environment.service"; import { FileUploadService as FileUploadServiceAbstraction } from "@bitwarden/common/platform/abstractions/file-upload/file-upload.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -193,13 +196,10 @@ import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.serv import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; import { ValidationService as ValidationServiceAbstraction } from "@bitwarden/common/platform/abstractions/validation.service"; -import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging"; // eslint-disable-next-line no-restricted-imports -- Used for dependency injection import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal"; import { devFlagEnabled } from "@bitwarden/common/platform/misc/flags"; -import { Account } from "@bitwarden/common/platform/models/domain/account"; -import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { NotificationsService } from "@bitwarden/common/platform/notifications"; // eslint-disable-next-line no-restricted-imports -- Needed for service creation import { @@ -222,17 +222,16 @@ import { DefaultBroadcasterService } from "@bitwarden/common/platform/services/d import { DefaultEnvironmentService } from "@bitwarden/common/platform/services/default-environment.service"; import { DefaultServerSettingsService } from "@bitwarden/common/platform/services/default-server-settings.service"; import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service"; -import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.service"; import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { DefaultSdkService } from "@bitwarden/common/platform/services/sdk/default-sdk.service"; -import { StateService } from "@bitwarden/common/platform/services/state.service"; import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider"; import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service"; import { ValidationService } from "@bitwarden/common/platform/services/validation.service"; import { ActiveUserAccessor, ActiveUserStateProvider, + DefaultStateService, DerivedStateProvider, GlobalStateProvider, SingleUserStateProvider, @@ -369,12 +368,10 @@ import { LOCKED_CALLBACK, LOG_MAC_FAILURES, LOGOUT_CALLBACK, - MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE, OBSERVABLE_MEMORY_STORAGE, REFRESH_ACCESS_TOKEN_ERROR_CALLBACK, SECURE_STORAGE, - STATE_FACTORY, SUPPORTS_SECURE_STORAGE, SYSTEM_LANGUAGE, SYSTEM_THEME_OBSERVABLE, @@ -412,10 +409,6 @@ const safeProviders: SafeProvider[] = [ useFactory: (window: Window) => window.navigator.language, deps: [WINDOW], }), - safeProvider({ - provide: STATE_FACTORY, - useValue: new StateFactory(GlobalState, Account), - }), // TODO: PM-21212 - Deprecate LogoutCallback in favor of LogoutService safeProvider({ provide: LOGOUT_CALLBACK, @@ -518,7 +511,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: DomainSettingsService, useClass: DefaultDomainSettingsService, - deps: [StateProvider, ConfigService], + deps: [StateProvider], }), safeProvider({ provide: CipherServiceAbstraction, @@ -528,7 +521,6 @@ const safeProviders: SafeProvider[] = [ apiService: ApiServiceAbstraction, i18nService: I18nServiceAbstraction, searchService: SearchServiceAbstraction, - stateService: StateServiceAbstraction, autofillSettingsService: AutofillSettingsServiceAbstraction, encryptService: EncryptService, fileUploadService: CipherFileUploadServiceAbstraction, @@ -545,7 +537,6 @@ const safeProviders: SafeProvider[] = [ apiService, i18nService, searchService, - stateService, autofillSettingsService, encryptService, fileUploadService, @@ -562,7 +553,6 @@ const safeProviders: SafeProvider[] = [ ApiServiceAbstraction, I18nServiceAbstraction, SearchServiceAbstraction, - StateServiceAbstraction, AutofillSettingsServiceAbstraction, EncryptService, CipherFileUploadServiceAbstraction, @@ -660,15 +650,15 @@ const safeProviders: SafeProvider[] = [ GlobalStateProvider, SUPPORTS_SECURE_STORAGE, SECURE_STORAGE, - KeyGenerationServiceAbstraction, + KeyGenerationService, EncryptService, LogService, LOGOUT_CALLBACK, ], }), safeProvider({ - provide: KeyGenerationServiceAbstraction, - useClass: KeyGenerationService, + provide: KeyGenerationService, + useClass: DefaultKeyGenerationService, deps: [CryptoFunctionServiceAbstraction], }), safeProvider({ @@ -677,7 +667,7 @@ const safeProviders: SafeProvider[] = [ deps: [ PinServiceAbstraction, InternalMasterPasswordServiceAbstraction, - KeyGenerationServiceAbstraction, + KeyGenerationService, CryptoFunctionServiceAbstraction, EncryptService, PlatformUtilsServiceAbstraction, @@ -767,7 +757,7 @@ const safeProviders: SafeProvider[] = [ deps: [ KeyService, I18nServiceAbstraction, - KeyGenerationServiceAbstraction, + KeyGenerationService, SendStateProviderAbstraction, EncryptService, ], @@ -799,7 +789,6 @@ const safeProviders: SafeProvider[] = [ InternalSendService, LogService, KeyConnectorServiceAbstraction, - StateServiceAbstraction, ProviderServiceAbstraction, FolderApiServiceAbstraction, InternalOrganizationServiceAbstraction, @@ -847,6 +836,7 @@ const safeProviders: SafeProvider[] = [ MessagingServiceAbstraction, SearchServiceAbstraction, StateServiceAbstraction, + TokenServiceAbstraction, AuthServiceAbstraction, VaultTimeoutSettingsService, StateEventRunnerService, @@ -866,24 +856,10 @@ const safeProviders: SafeProvider[] = [ useClass: SsoLoginService, deps: [StateProvider, LogService], }), - safeProvider({ - provide: STATE_FACTORY, - useValue: new StateFactory(GlobalState, Account), - }), safeProvider({ provide: StateServiceAbstraction, - useClass: StateService, - deps: [ - AbstractStorageService, - SECURE_STORAGE, - MEMORY_STORAGE, - LogService, - STATE_FACTORY, - AccountServiceAbstraction, - EnvironmentService, - TokenServiceAbstraction, - MigrationRunner, - ], + useClass: DefaultStateService, + deps: [AbstractStorageService, SECURE_STORAGE, ActiveUserAccessor], }), safeProvider({ provide: IndividualVaultExportServiceAbstraction, @@ -1013,7 +989,7 @@ const safeProviders: SafeProvider[] = [ deps: [ StateProvider, StateServiceAbstraction, - KeyGenerationServiceAbstraction, + KeyGenerationService, EncryptService, LogService, CryptoFunctionServiceAbstraction, @@ -1035,7 +1011,7 @@ const safeProviders: SafeProvider[] = [ TokenServiceAbstraction, LogService, OrganizationServiceAbstraction, - KeyGenerationServiceAbstraction, + KeyGenerationService, LOGOUT_CALLBACK, StateProvider, ], @@ -1194,7 +1170,7 @@ const safeProviders: SafeProvider[] = [ provide: DeviceTrustServiceAbstraction, useClass: DeviceTrustService, deps: [ - KeyGenerationServiceAbstraction, + KeyGenerationService, CryptoFunctionServiceAbstraction, KeyService, EncryptService, @@ -1230,7 +1206,7 @@ const safeProviders: SafeProvider[] = [ CryptoFunctionServiceAbstraction, EncryptService, KdfConfigService, - KeyGenerationServiceAbstraction, + KeyGenerationService, LogService, StateProvider, ], diff --git a/libs/angular/src/vault/components/spotlight/spotlight.component.html b/libs/angular/src/vault/components/spotlight/spotlight.component.html index e445640cff9..0d0e95e191b 100644 --- a/libs/angular/src/vault/components/spotlight/spotlight.component.html +++ b/libs/angular/src/vault/components/spotlight/spotlight.component.html @@ -18,9 +18,8 @@ size="small" *ngIf="!persistent" (click)="handleDismiss()" - [attr.title]="'close' | i18n" - [attr.aria-label]="'close' | i18n" class="-tw-me-2" + [label]="'close' | i18n" > diff --git a/libs/auth/src/angular/input-password/input-password.component.html b/libs/auth/src/angular/input-password/input-password.component.html index d39215b2d68..d56fe6a27fc 100644 --- a/libs/auth/src/angular/input-password/input-password.component.html +++ b/libs/auth/src/angular/input-password/input-password.component.html @@ -45,7 +45,7 @@ type="button" bitIconButton="bwi-generate" bitSuffix - [appA11yTitle]="'generatePassword' | i18n" + [label]="'generatePassword' | i18n" (click)="generatePassword()" > - + ``` ## `[bitSubmit]` Disabled Form Submit diff --git a/libs/components/src/async-actions/in-forms.stories.ts b/libs/components/src/async-actions/in-forms.stories.ts index dd901cd2477..88383fe85a3 100644 --- a/libs/components/src/async-actions/in-forms.stories.ts +++ b/libs/components/src/async-actions/in-forms.stories.ts @@ -7,6 +7,7 @@ import { delay, of } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { A11yTitleDirective } from "../a11y"; import { ButtonModule } from "../button"; import { FormFieldModule } from "../form-field"; import { IconButtonModule } from "../icon-button"; @@ -28,20 +29,21 @@ const template = ` Email - + - + `; @Component({ selector: "app-promise-example", template, imports: [ + A11yTitleDirective, AsyncActionsModule, ButtonModule, FormFieldModule, @@ -86,6 +88,7 @@ class PromiseExampleComponent { selector: "app-observable-example", template, imports: [ + A11yTitleDirective, AsyncActionsModule, ButtonModule, FormFieldModule, diff --git a/libs/components/src/async-actions/standalone.mdx b/libs/components/src/async-actions/standalone.mdx index f484ea01c58..a781f40d852 100644 --- a/libs/components/src/async-actions/standalone.mdx +++ b/libs/components/src/async-actions/standalone.mdx @@ -63,7 +63,7 @@ from how click handlers are usually defined with the output syntax `(click)="han ```html -`; +`; ``` ## Stories diff --git a/libs/components/src/async-actions/standalone.stories.ts b/libs/components/src/async-actions/standalone.stories.ts index 1ed6f6c5a59..99cde70566b 100644 --- a/libs/components/src/async-actions/standalone.stories.ts +++ b/libs/components/src/async-actions/standalone.stories.ts @@ -16,7 +16,7 @@ const template = /*html*/ ` - `; + `; @Component({ template, diff --git a/libs/components/src/avatar/avatar.component.ts b/libs/components/src/avatar/avatar.component.ts index fca5a457fac..59a9492f8c8 100644 --- a/libs/components/src/avatar/avatar.component.ts +++ b/libs/components/src/avatar/avatar.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { NgClass } from "@angular/common"; import { Component, OnChanges, input } from "@angular/core"; import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; @@ -41,7 +39,7 @@ export class AvatarComponent implements OnChanges { private svgFontSize = 20; private svgFontWeight = 300; private svgSize = 48; - src: SafeResourceUrl; + src?: SafeResourceUrl; constructor(public sanitizer: DomSanitizer) {} @@ -56,8 +54,14 @@ export class AvatarComponent implements OnChanges { } private generate() { - let chars: string = null; - const upperCaseText = this.text()?.toUpperCase() ?? ""; + const color = this.color(); + const text = this.text(); + const id = this.id(); + if (!text && !color && !id) { + throw new Error("Must supply `text`, `color`, or `id` input."); + } + let chars: string | null = null; + const upperCaseText = text?.toUpperCase() ?? ""; chars = this.getFirstLetters(upperCaseText, this.svgCharCount); @@ -66,18 +70,17 @@ export class AvatarComponent implements OnChanges { } // If the chars contain an emoji, only show it. - if (chars.match(Utils.regexpEmojiPresentation)) { - chars = chars.match(Utils.regexpEmojiPresentation)[0]; + const emojiMatch = chars.match(Utils.regexpEmojiPresentation); + if (emojiMatch) { + chars = emojiMatch[0]; } let svg: HTMLElement; - let hexColor = this.color(); - - const id = this.id(); - if (!Utils.isNullOrWhitespace(this.color())) { + let hexColor = color ?? ""; + if (!Utils.isNullOrWhitespace(hexColor)) { svg = this.createSvgElement(this.svgSize, hexColor); - } else if (!Utils.isNullOrWhitespace(id)) { - hexColor = Utils.stringToColor(id.toString()); + } else if (!Utils.isNullOrWhitespace(id ?? "")) { + hexColor = Utils.stringToColor(id!.toString()); svg = this.createSvgElement(this.svgSize, hexColor); } else { hexColor = Utils.stringToColor(upperCaseText); @@ -95,7 +98,7 @@ export class AvatarComponent implements OnChanges { ); } - private getFirstLetters(data: string, count: number): string { + private getFirstLetters(data: string, count: number): string | null { const parts = data.split(" "); if (parts.length > 1) { let text = ""; diff --git a/libs/components/src/banner/banner.component.html b/libs/components/src/banner/banner.component.html index 581a56d86cb..bfde8135da9 100644 --- a/libs/components/src/banner/banner.component.html +++ b/libs/components/src/banner/banner.component.html @@ -19,8 +19,7 @@ buttonType="main" size="small" (click)="onClose.emit()" - [attr.title]="'close' | i18n" - [attr.aria-label]="'close' | i18n" + [label]="'close' | i18n" > } diff --git a/libs/components/src/breadcrumbs/breadcrumb.component.ts b/libs/components/src/breadcrumbs/breadcrumb.component.ts index 54678f3e4ee..783cb2655f7 100644 --- a/libs/components/src/breadcrumbs/breadcrumb.component.ts +++ b/libs/components/src/breadcrumbs/breadcrumb.component.ts @@ -1,7 +1,4 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore - -import { Component, EventEmitter, Output, TemplateRef, ViewChild, input } from "@angular/core"; +import { Component, EventEmitter, Output, TemplateRef, input, viewChild } from "@angular/core"; import { QueryParamsHandling } from "@angular/router"; @Component({ @@ -20,7 +17,7 @@ export class BreadcrumbComponent { @Output() click = new EventEmitter(); - @ViewChild(TemplateRef, { static: true }) content: TemplateRef; + readonly content = viewChild(TemplateRef); onClick(args: unknown) { this.click.next(args); diff --git a/libs/components/src/breadcrumbs/breadcrumbs.component.html b/libs/components/src/breadcrumbs/breadcrumbs.component.html index 820b100afd3..b63b21de76b 100644 --- a/libs/components/src/breadcrumbs/breadcrumbs.component.html +++ b/libs/components/src/breadcrumbs/breadcrumbs.component.html @@ -8,7 +8,7 @@ [queryParams]="breadcrumb.queryParams()" [queryParamsHandling]="breadcrumb.queryParamsHandling()" > - + } @else { } @if (!last) { @@ -35,6 +35,7 @@ bitIconButton="bwi-ellipsis-h" [bitMenuTriggerFor]="overflowMenu" size="small" + [label]="'moreBreadcrumbs' | i18n" > @for (breadcrumb of overflow; track breadcrumb) { @@ -46,11 +47,11 @@ [queryParams]="breadcrumb.queryParams()" [queryParamsHandling]="breadcrumb.queryParamsHandling()" > - + } @else { } } @@ -66,7 +67,7 @@ [queryParams]="breadcrumb.queryParams()" [queryParamsHandling]="breadcrumb.queryParamsHandling()" > - + } @else { } @if (!last) { diff --git a/libs/components/src/breadcrumbs/breadcrumbs.component.ts b/libs/components/src/breadcrumbs/breadcrumbs.component.ts index a1a6e732459..3c24f91be99 100644 --- a/libs/components/src/breadcrumbs/breadcrumbs.component.ts +++ b/libs/components/src/breadcrumbs/breadcrumbs.component.ts @@ -2,6 +2,8 @@ import { CommonModule } from "@angular/common"; import { Component, ContentChildren, QueryList, input } from "@angular/core"; import { RouterModule } from "@angular/router"; +import { I18nPipe } from "@bitwarden/ui-common"; + import { IconButtonModule } from "../icon-button"; import { LinkModule } from "../link"; import { MenuModule } from "../menu"; @@ -16,7 +18,7 @@ import { BreadcrumbComponent } from "./breadcrumb.component"; @Component({ selector: "bit-breadcrumbs", templateUrl: "./breadcrumbs.component.html", - imports: [CommonModule, LinkModule, RouterModule, IconButtonModule, MenuModule], + imports: [I18nPipe, CommonModule, LinkModule, RouterModule, IconButtonModule, MenuModule], }) export class BreadcrumbsComponent { readonly show = input(3); diff --git a/libs/components/src/breadcrumbs/breadcrumbs.stories.ts b/libs/components/src/breadcrumbs/breadcrumbs.stories.ts index 98af3c0ae7b..893f645a913 100644 --- a/libs/components/src/breadcrumbs/breadcrumbs.stories.ts +++ b/libs/components/src/breadcrumbs/breadcrumbs.stories.ts @@ -2,9 +2,12 @@ import { Component, importProvidersFrom } from "@angular/core"; import { RouterModule } from "@angular/router"; import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + import { IconButtonModule } from "../icon-button"; import { LinkModule } from "../link"; import { MenuModule } from "../menu"; +import { I18nMockService } from "../utils"; import { BreadcrumbComponent } from "./breadcrumb.component"; import { BreadcrumbsComponent } from "./breadcrumbs.component"; @@ -26,6 +29,16 @@ export default { decorators: [ moduleMetadata({ imports: [LinkModule, MenuModule, IconButtonModule, RouterModule, BreadcrumbComponent], + providers: [ + { + provide: I18nService, + useFactory: () => { + return new I18nMockService({ + moreBreadcrumbs: "More breadcrumbs", + }); + }, + }, + ], }), applicationConfig({ providers: [ diff --git a/libs/components/src/callout/callout.component.html b/libs/components/src/callout/callout.component.html index b990e57a767..b98679766d5 100644 --- a/libs/components/src/callout/callout.component.html +++ b/libs/components/src/callout/callout.component.html @@ -1,6 +1,6 @@

- + diff --git a/libs/components/src/drawer/drawer.stories.ts b/libs/components/src/drawer/drawer.stories.ts index a524c9a7a1a..1d7aa79137d 100644 --- a/libs/components/src/drawer/drawer.stories.ts +++ b/libs/components/src/drawer/drawer.stories.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { RouterTestingModule } from "@angular/router/testing"; import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; diff --git a/libs/components/src/form-control/form-control.abstraction.ts b/libs/components/src/form-control/form-control.abstraction.ts index 4c0a543e672..ac5a249667d 100644 --- a/libs/components/src/form-control/form-control.abstraction.ts +++ b/libs/components/src/form-control/form-control.abstraction.ts @@ -1,8 +1,6 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore export abstract class BitFormControlAbstraction { - disabled: boolean; - required: boolean; - hasError: boolean; - error: [string, any]; + abstract disabled: boolean; + abstract required: boolean; + abstract hasError: boolean; + abstract error: [string, any]; } diff --git a/libs/components/src/form-control/form-control.component.html b/libs/components/src/form-control/form-control.component.html index 15d422b01a1..3dc3f90c9b0 100644 --- a/libs/components/src/form-control/form-control.component.html +++ b/libs/components/src/form-control/form-control.component.html @@ -1,11 +1,11 @@
diff --git a/libs/components/src/item/item.stories.ts b/libs/components/src/item/item.stories.ts index 15b81f3f67e..bc2ced7fd11 100644 --- a/libs/components/src/item/item.stories.ts +++ b/libs/components/src/item/item.stories.ts @@ -77,10 +77,10 @@ export const Default: Story = { - + - + @@ -150,10 +150,10 @@ export const TextOverflowTruncate: Story = { - + - + @@ -173,10 +173,10 @@ export const TextOverflowWrap: Story = { - + - + @@ -198,10 +198,10 @@ const multipleActionListTemplate = /*html*/ ` - + - + @@ -217,10 +217,10 @@ const multipleActionListTemplate = /*html*/ ` - + - + @@ -236,10 +236,10 @@ const multipleActionListTemplate = /*html*/ ` - + - + @@ -255,10 +255,10 @@ const multipleActionListTemplate = /*html*/ ` - + - + @@ -274,10 +274,10 @@ const multipleActionListTemplate = /*html*/ ` - + - + @@ -293,10 +293,10 @@ const multipleActionListTemplate = /*html*/ ` - + - + @@ -410,10 +410,10 @@ export const VirtualScrolling: Story = { - + - + @@ -440,10 +440,10 @@ export const WithoutBorderRadius: Story = { - + - + diff --git a/libs/components/src/menu/menu-trigger-for.directive.ts b/libs/components/src/menu/menu-trigger-for.directive.ts index 94353299c5d..2eab12995ea 100644 --- a/libs/components/src/menu/menu-trigger-for.directive.ts +++ b/libs/components/src/menu/menu-trigger-for.directive.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { hasModifierKey } from "@angular/cdk/keycodes"; import { Overlay, OverlayConfig, OverlayRef } from "@angular/cdk/overlay"; import { TemplatePortal } from "@angular/cdk/portal"; @@ -31,9 +29,9 @@ export class MenuTriggerForDirective implements OnDestroy { readonly role = input("button"); - readonly menu = input(undefined, { alias: "bitMenuTriggerFor" }); + readonly menu = input.required({ alias: "bitMenuTriggerFor" }); - private overlayRef: OverlayRef; + private overlayRef: OverlayRef | null = null; private defaultMenuConfig: OverlayConfig = { panelClass: "bit-menu-panel", hasBackdrop: true, @@ -52,8 +50,8 @@ export class MenuTriggerForDirective implements OnDestroy { .withFlexibleDimensions(false) .withPush(true), }; - private closedEventsSub: Subscription; - private keyDownEventsSub: Subscription; + private closedEventsSub: Subscription | null = null; + private keyDownEventsSub: Subscription | null = null; constructor( private elementRef: ElementRef, @@ -78,28 +76,30 @@ export class MenuTriggerForDirective implements OnDestroy { this.isOpen = true; this.overlayRef = this.overlay.create(this.defaultMenuConfig); - const templatePortal = new TemplatePortal(menu.templateRef, this.viewContainerRef); + const templatePortal = new TemplatePortal(menu.templateRef(), this.viewContainerRef); this.overlayRef.attach(templatePortal); - this.closedEventsSub = this.getClosedEvents().subscribe((event: KeyboardEvent | undefined) => { - // Closing the menu is handled in this.destroyMenu, so we want to prevent the escape key - // from doing its normal default action, which would otherwise cause a parent component - // (like a dialog) or extension window to close - if (event?.key === "Escape" && !hasModifierKey(event)) { - event.preventDefault(); - } + this.closedEventsSub = + this.getClosedEvents()?.subscribe((event: KeyboardEvent | undefined) => { + // Closing the menu is handled in this.destroyMenu, so we want to prevent the escape key + // from doing its normal default action, which would otherwise cause a parent component + // (like a dialog) or extension window to close + if (event?.key === "Escape" && !hasModifierKey(event)) { + event.preventDefault(); + } + + if (event?.key && ["Tab", "Escape"].includes(event.key)) { + // Required to ensure tab order resumes correctly + this.elementRef.nativeElement.focus(); + } + this.destroyMenu(); + }) ?? null; - if (["Tab", "Escape"].includes(event?.key)) { - // Required to ensure tab order resumes correctly - this.elementRef.nativeElement.focus(); - } - this.destroyMenu(); - }); if (menu.keyManager) { menu.keyManager.setFirstItemActive(); this.keyDownEventsSub = this.overlayRef .keydownEvents() - .subscribe((event: KeyboardEvent) => this.menu().keyManager.onKeydown(event)); + .subscribe((event: KeyboardEvent) => this.menu().keyManager?.onKeydown(event)); } } @@ -113,7 +113,10 @@ export class MenuTriggerForDirective implements OnDestroy { this.menu().closed.emit(); } - private getClosedEvents(): Observable { + private getClosedEvents(): Observable | null { + if (!this.overlayRef) { + return null; + } const detachments = this.overlayRef.detachments(); const escKey = this.overlayRef.keydownEvents().pipe( filter((event: KeyboardEvent) => { diff --git a/libs/components/src/menu/menu.component.ts b/libs/components/src/menu/menu.component.ts index 0a76d59a09c..3cc4de9f90f 100644 --- a/libs/components/src/menu/menu.component.ts +++ b/libs/components/src/menu/menu.component.ts @@ -1,16 +1,13 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { FocusKeyManager, CdkTrapFocus } from "@angular/cdk/a11y"; import { Component, Output, TemplateRef, - ViewChild, EventEmitter, - ContentChildren, - QueryList, AfterContentInit, input, + viewChild, + contentChildren, } from "@angular/core"; import { MenuItemDirective } from "./menu-item.directive"; @@ -22,10 +19,9 @@ import { MenuItemDirective } from "./menu-item.directive"; imports: [CdkTrapFocus], }) export class MenuComponent implements AfterContentInit { - @ViewChild(TemplateRef) templateRef: TemplateRef; + readonly templateRef = viewChild.required(TemplateRef); @Output() closed = new EventEmitter(); - @ContentChildren(MenuItemDirective, { descendants: true }) - menuItems: QueryList; + readonly menuItems = contentChildren(MenuItemDirective, { descendants: true }); keyManager?: FocusKeyManager; readonly ariaRole = input<"menu" | "dialog">("menu"); @@ -34,9 +30,9 @@ export class MenuComponent implements AfterContentInit { ngAfterContentInit() { if (this.ariaRole() === "menu") { - this.keyManager = new FocusKeyManager(this.menuItems) + this.keyManager = new FocusKeyManager(this.menuItems()) .withWrap() - .skipPredicate((item) => item.disabled); + .skipPredicate((item) => !!item.disabled); } } } diff --git a/libs/components/src/menu/menu.stories.ts b/libs/components/src/menu/menu.stories.ts index 7c4894cbb2f..b29613061b8 100644 --- a/libs/components/src/menu/menu.stories.ts +++ b/libs/components/src/menu/menu.stories.ts @@ -46,7 +46,7 @@ export const OpenMenu: Story = {
- +
`, diff --git a/libs/components/src/multi-select/multi-select.component.ts b/libs/components/src/multi-select/multi-select.component.ts index af1fd8bab42..3e72318ba35 100644 --- a/libs/components/src/multi-select/multi-select.component.ts +++ b/libs/components/src/multi-select/multi-select.component.ts @@ -1,12 +1,9 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { hasModifierKey } from "@angular/cdk/keycodes"; import { Component, Input, OnInit, Output, - ViewChild, EventEmitter, HostBinding, Optional, @@ -14,6 +11,7 @@ import { input, model, booleanAttribute, + viewChild, } from "@angular/core"; import { ControlValueAccessor, @@ -48,10 +46,10 @@ let nextId = 0; * This component has been implemented to only support Multi-select list events */ export class MultiSelectComponent implements OnInit, BitFormFieldControl, ControlValueAccessor { - @ViewChild(NgSelectComponent) select: NgSelectComponent; + readonly select = viewChild.required(NgSelectComponent); // Parent component should only pass selectable items (complete list - selected items = baseItems) - readonly baseItems = model(); + readonly baseItems = model.required(); // Defaults to native ng-select behavior - set to "true" to clear selected items on dropdown close readonly removeSelectedItems = input(false); readonly placeholder = model(); @@ -61,10 +59,10 @@ export class MultiSelectComponent implements OnInit, BitFormFieldControl, Contro @Input({ transform: booleanAttribute }) disabled?: boolean; // Internal tracking of selected items - protected selectedItems: SelectItemView[]; + protected selectedItems: SelectItemView[] | null = null; // Default values for our implementation - loadingText: string; + loadingText?: string; protected searchInputId = `search-input-${nextId++}`; @@ -95,13 +93,14 @@ export class MultiSelectComponent implements OnInit, BitFormFieldControl, Contro /** Function for customizing keyboard navigation */ /** Needs to be arrow function to retain `this` scope. */ keyDown = (event: KeyboardEvent) => { - if (!this.select.isOpen && event.key === "Enter" && !hasModifierKey(event)) { + const select = this.select(); + if (!select.isOpen && event.key === "Enter" && !hasModifierKey(event)) { return false; } - if (this.select.isOpen && event.key === "Escape" && !hasModifierKey(event)) { + if (select.isOpen && event.key === "Escape" && !hasModifierKey(event)) { this.selectedItems = []; - this.select.close(); + select.close(); event.stopPropagation(); return false; } @@ -183,11 +182,11 @@ export class MultiSelectComponent implements OnInit, BitFormFieldControl, Contro get ariaDescribedBy() { return this._ariaDescribedBy; } - set ariaDescribedBy(value: string) { + set ariaDescribedBy(value: string | undefined) { this._ariaDescribedBy = value; - this.select?.searchInput.nativeElement.setAttribute("aria-describedby", value); + this.select()?.searchInput.nativeElement.setAttribute("aria-describedby", value ?? ""); } - private _ariaDescribedBy: string; + private _ariaDescribedBy?: string; /**Implemented as part of BitFormFieldControl */ get labelForId() { @@ -208,16 +207,17 @@ export class MultiSelectComponent implements OnInit, BitFormFieldControl, Contro set required(value: any) { this._required = value != null && value !== false; } - private _required: boolean; + private _required?: boolean; /**Implemented as part of BitFormFieldControl */ get hasError() { - return this.ngControl?.status === "INVALID" && this.ngControl?.touched; + return !!(this.ngControl?.status === "INVALID" && this.ngControl?.touched); } /**Implemented as part of BitFormFieldControl */ get error(): [string, any] { - const key = Object.keys(this.ngControl?.errors)[0]; - return [key, this.ngControl?.errors[key]]; + const errors = this.ngControl?.errors ?? {}; + const key = Object.keys(errors)[0]; + return [key, errors[key]]; } } diff --git a/libs/components/src/navigation/nav-base.component.ts b/libs/components/src/navigation/nav-base.component.ts index 0fb73740273..1ca40545cbb 100644 --- a/libs/components/src/navigation/nav-base.component.ts +++ b/libs/components/src/navigation/nav-base.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Directive, EventEmitter, Output, input } from "@angular/core"; import { RouterLink, RouterLinkActive } from "@angular/router"; diff --git a/libs/components/src/navigation/nav-group.component.html b/libs/components/src/navigation/nav-group.component.html index f27dd5ba10e..195569292f6 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 (!hideIfEmpty() || nestedNavComponents().length > 0) { diff --git a/libs/components/src/navigation/nav-group.component.ts b/libs/components/src/navigation/nav-group.component.ts index 6e73d67f77a..1fa03ef52be 100644 --- a/libs/components/src/navigation/nav-group.component.ts +++ b/libs/components/src/navigation/nav-group.component.ts @@ -2,14 +2,13 @@ import { CommonModule } from "@angular/common"; import { booleanAttribute, Component, - ContentChildren, EventEmitter, Optional, Output, - QueryList, SkipSelf, input, model, + contentChildren, } from "@angular/core"; import { I18nPipe } from "@bitwarden/ui-common"; @@ -30,10 +29,7 @@ import { SideNavService } from "./side-nav.service"; imports: [CommonModule, NavItemComponent, IconButtonModule, I18nPipe], }) export class NavGroupComponent extends NavBaseComponent { - @ContentChildren(NavBaseComponent, { - descendants: true, - }) - nestedNavComponents!: QueryList; + readonly nestedNavComponents = contentChildren(NavBaseComponent, { descendants: true }); /** When the side nav is open, the parent nav item should not show active styles when open. */ protected get parentHideActiveStyles(): boolean { diff --git a/libs/components/src/navigation/nav-item.stories.ts b/libs/components/src/navigation/nav-item.stories.ts index c3f7e526ecb..dbd17695c8b 100644 --- a/libs/components/src/navigation/nav-item.stories.ts +++ b/libs/components/src/navigation/nav-item.stories.ts @@ -95,7 +95,7 @@ export const WithChildButtons: Story = { [bitIconButton]="'bwi-pencil-square'" [buttonType]="'nav-contrast'" size="small" - aria-label="option 2" + label="Edit" > `, diff --git a/libs/components/src/navigation/nav-logo.component.ts b/libs/components/src/navigation/nav-logo.component.ts index 74b44266074..52d67446f2f 100644 --- a/libs/components/src/navigation/nav-logo.component.ts +++ b/libs/components/src/navigation/nav-logo.component.ts @@ -1,6 +1,3 @@ -// 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 } from "@angular/core"; import { RouterLinkActive, RouterLink } from "@angular/router"; diff --git a/libs/components/src/navigation/side-nav.component.html b/libs/components/src/navigation/side-nav.component.html index a5866b5e42e..1fdd40b1588 100644 --- a/libs/components/src/navigation/side-nav.component.html +++ b/libs/components/src/navigation/side-nav.component.html @@ -37,7 +37,7 @@ buttonType="nav-contrast" size="small" (click)="sideNavService.toggle()" - [attr.aria-label]="'toggleSideNavigation' | i18n" + [label]="'toggleSideNavigation' | i18n" [attr.aria-expanded]="data.open" aria-controls="bit-side-nav" > diff --git a/libs/components/src/navigation/side-nav.component.ts b/libs/components/src/navigation/side-nav.component.ts index 75365b738af..fe428c7011f 100644 --- a/libs/components/src/navigation/side-nav.component.ts +++ b/libs/components/src/navigation/side-nav.component.ts @@ -1,8 +1,6 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CdkTrapFocus } from "@angular/cdk/a11y"; import { CommonModule } from "@angular/common"; -import { Component, ElementRef, ViewChild, input } from "@angular/core"; +import { Component, ElementRef, input, viewChild } from "@angular/core"; import { I18nPipe } from "@bitwarden/ui-common"; @@ -21,15 +19,14 @@ export type SideNavVariant = "primary" | "secondary"; export class SideNavComponent { readonly variant = input("primary"); - @ViewChild("toggleButton", { read: ElementRef, static: true }) - private toggleButton: ElementRef; + private readonly toggleButton = viewChild("toggleButton", { read: ElementRef }); constructor(protected sideNavService: SideNavService) {} protected handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { this.sideNavService.setClose(); - this.toggleButton?.nativeElement.focus(); + this.toggleButton()?.nativeElement.focus(); return false; } diff --git a/libs/components/src/popover/popover-trigger-for.directive.ts b/libs/components/src/popover/popover-trigger-for.directive.ts index d21ea9d7ed6..cb114f1fbc3 100644 --- a/libs/components/src/popover/popover-trigger-for.directive.ts +++ b/libs/components/src/popover/popover-trigger-for.directive.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Overlay, OverlayConfig, OverlayRef } from "@angular/cdk/overlay"; import { TemplatePortal } from "@angular/cdk/portal"; import { @@ -27,12 +25,12 @@ import { PopoverComponent } from "./popover.component"; export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { readonly popoverOpen = model(false); - readonly popover = input(undefined, { alias: "bitPopoverTriggerFor" }); + readonly popover = input.required({ alias: "bitPopoverTriggerFor" }); readonly position = input(); - private overlayRef: OverlayRef; - private closedEventsSub: Subscription; + private overlayRef: OverlayRef | null = null; + private closedEventsSub: Subscription | null = null; get positions() { if (!this.position()) { @@ -82,7 +80,7 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { this.popoverOpen.set(true); this.overlayRef = this.overlay.create(this.defaultPopoverConfig); - const templatePortal = new TemplatePortal(this.popover().templateRef, this.viewContainerRef); + const templatePortal = new TemplatePortal(this.popover().templateRef(), this.viewContainerRef); this.overlayRef.attach(templatePortal); this.closedEventsSub = this.getClosedEvents().subscribe(() => { @@ -91,6 +89,10 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { } private getClosedEvents(): Observable { + if (!this.overlayRef) { + throw new Error("Overlay reference is not available"); + } + const detachments = this.overlayRef.detachments(); const escKey = this.overlayRef .keydownEvents() @@ -102,7 +104,7 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { } private destroyPopover() { - if (this.overlayRef == null || !this.popoverOpen()) { + if (!this.overlayRef || !this.popoverOpen()) { return; } @@ -112,7 +114,9 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { private disposeAll() { this.closedEventsSub?.unsubscribe(); + this.closedEventsSub = null; this.overlayRef?.dispose(); + this.overlayRef = null; } ngAfterViewInit() { diff --git a/libs/components/src/popover/popover.component.html b/libs/components/src/popover/popover.component.html index 328da284732..756ac27b749 100644 --- a/libs/components/src/popover/popover.component.html +++ b/libs/components/src/popover/popover.component.html @@ -18,8 +18,7 @@ diff --git a/libs/components/src/popover/popover.component.ts b/libs/components/src/popover/popover.component.ts index d4177105584..78bc2abe369 100644 --- a/libs/components/src/popover/popover.component.ts +++ b/libs/components/src/popover/popover.component.ts @@ -1,7 +1,5 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { A11yModule } from "@angular/cdk/a11y"; -import { Component, EventEmitter, Output, TemplateRef, ViewChild, input } from "@angular/core"; +import { Component, EventEmitter, Output, TemplateRef, input, viewChild } from "@angular/core"; import { IconButtonModule } from "../icon-button/icon-button.module"; import { SharedModule } from "../shared/shared.module"; @@ -14,7 +12,7 @@ import { TypographyModule } from "../typography"; exportAs: "popoverComponent", }) export class PopoverComponent { - @ViewChild(TemplateRef) templateRef: TemplateRef; + readonly templateRef = viewChild.required(TemplateRef); readonly title = input(""); @Output() closed = new EventEmitter(); } diff --git a/libs/components/src/radio-button/radio-group.component.html b/libs/components/src/radio-button/radio-group.component.html index b71abd9249c..e16c532620a 100644 --- a/libs/components/src/radio-button/radio-group.component.html +++ b/libs/components/src/radio-button/radio-group.component.html @@ -1,4 +1,4 @@ -@if (label) { +@if (label()) {
@@ -10,7 +10,7 @@
} -@if (!label) { +@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 0f6a04486f7..baa2cd58d80 100644 --- a/libs/components/src/radio-button/radio-group.component.ts +++ b/libs/components/src/radio-button/radio-group.component.ts @@ -1,7 +1,5 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { NgTemplateOutlet } from "@angular/common"; -import { Component, ContentChild, HostBinding, Optional, Input, Self, input } from "@angular/core"; +import { Component, HostBinding, Optional, Self, input, contentChild } from "@angular/core"; import { ControlValueAccessor, NgControl, Validators } from "@angular/forms"; import { I18nPipe } from "@bitwarden/ui-common"; @@ -22,14 +20,8 @@ export class RadioGroupComponent implements ControlValueAccessor { selected: unknown; disabled = false; - // TODO: Skipped for signal migration because: - // Accessor inputs cannot be migrated as they are too complex. - private _name?: string; - @Input() get name() { - return this._name ?? this.ngControl?.name?.toString(); - } - set name(value: string) { - this._name = value; + get name() { + return this.ngControl?.name?.toString(); } readonly block = input(false); @@ -38,7 +30,7 @@ export class RadioGroupComponent implements ControlValueAccessor { readonly id = input(`bit-radio-group-${nextId++}`); @HostBinding("class") classList = ["tw-block", "tw-mb-4"]; - @ContentChild(BitLabel) protected label: BitLabel; + protected readonly label = contentChild(BitLabel); constructor(@Optional() @Self() private ngControl?: NgControl) { if (ngControl != null) { @@ -51,8 +43,8 @@ export class RadioGroupComponent implements ControlValueAccessor { } // ControlValueAccessor - onChange: (value: unknown) => void; - onTouched: () => void; + onChange?: (value: unknown) => void; + onTouched?: () => void; writeValue(value: boolean): void { this.selected = value; @@ -72,10 +64,10 @@ export class RadioGroupComponent implements ControlValueAccessor { onInputChange(value: unknown) { this.selected = value; - this.onChange(this.selected); + this.onChange?.(this.selected); } onBlur() { - this.onTouched(); + this.onTouched?.(); } } diff --git a/libs/components/src/radio-button/radio-input.component.ts b/libs/components/src/radio-button/radio-input.component.ts index 33100db1679..0149ffdd284 100644 --- a/libs/components/src/radio-button/radio-input.component.ts +++ b/libs/components/src/radio-button/radio-input.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Component, HostBinding, input, Input, Optional, Self } from "@angular/core"; import { NgControl, Validators } from "@angular/forms"; @@ -86,7 +84,7 @@ export class RadioInputComponent implements BitFormControlAbstraction { set disabled(value: any) { this._disabled = value != null && value !== false; } - private _disabled: boolean; + private _disabled?: boolean; // TODO: Skipped for signal migration because: // Accessor inputs cannot be migrated as they are too complex. @@ -99,14 +97,15 @@ export class RadioInputComponent implements BitFormControlAbstraction { set required(value: any) { this._required = value != null && value !== false; } - private _required: boolean; + private _required?: boolean; get hasError() { - return this.ngControl?.status === "INVALID" && this.ngControl?.touched; + return !!(this.ngControl?.status === "INVALID" && this.ngControl?.touched); } get error(): [string, any] { - const key = Object.keys(this.ngControl.errors)[0]; - return [key, this.ngControl.errors[key]]; + const errors = this.ngControl?.errors ?? {}; + const key = Object.keys(errors)[0]; + return [key, errors[key]]; } } diff --git a/libs/components/src/search/search.component.ts b/libs/components/src/search/search.component.ts index c6c5f2757dd..65568b241fb 100644 --- a/libs/components/src/search/search.component.ts +++ b/libs/components/src/search/search.component.ts @@ -1,7 +1,5 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { NgIf, NgClass } from "@angular/common"; -import { Component, ElementRef, ViewChild, input, model, signal, computed } from "@angular/core"; +import { Component, ElementRef, input, model, signal, computed, viewChild } from "@angular/core"; import { ControlValueAccessor, NG_VALUE_ACCESSOR, @@ -37,13 +35,13 @@ let nextId = 0; imports: [InputModule, ReactiveFormsModule, FormsModule, I18nPipe, NgIf, NgClass], }) export class SearchComponent implements ControlValueAccessor, FocusableElement { - private notifyOnChange: (v: string) => void; - private notifyOnTouch: () => void; + private notifyOnChange?: (v: string) => void; + private notifyOnTouch?: () => void; - @ViewChild("input") private input: ElementRef; + private readonly input = viewChild>("input"); protected id = `search-id-${nextId++}`; - protected searchText: string; + protected searchText?: string; // Use `type="text"` for Safari to improve rendering performance protected inputType = isBrowserSafariApi() ? ("text" as const) : ("search" as const); @@ -57,7 +55,7 @@ export class SearchComponent implements ControlValueAccessor, FocusableElement { readonly autocomplete = input(); getFocusTarget() { - return this.input?.nativeElement; + return this.input()?.nativeElement; } onChange(searchText: string) { diff --git a/libs/components/src/section/section.mdx b/libs/components/src/section/section.mdx index 92798420479..81d98101c06 100644 --- a/libs/components/src/section/section.mdx +++ b/libs/components/src/section/section.mdx @@ -51,7 +51,7 @@ padding to align the header with the border radius of the card/item.

I'm a section header

- +

I'm card content

diff --git a/libs/components/src/section/section.stories.ts b/libs/components/src/section/section.stories.ts index f28cca0af7b..31cdd0a324c 100644 --- a/libs/components/src/section/section.stories.ts +++ b/libs/components/src/section/section.stories.ts @@ -69,7 +69,7 @@ export const HeaderVariants: Story = {

Title with icon button suffix

- + `, }), @@ -88,7 +88,7 @@ export const HeaderEndSlotVariants: Story = {

Title with end slot icon button

- + `, }), @@ -103,7 +103,7 @@ export const HeaderWithPadding: Story = {

Card as immediate sibling

- +

bit-section-header has padding

@@ -114,7 +114,7 @@ export const HeaderWithPadding: Story = {

Card nested in immediate sibling

- +
@@ -127,7 +127,7 @@ export const HeaderWithPadding: Story = {

Item as immediate sibling

- + bit-section-header has padding @@ -138,7 +138,7 @@ export const HeaderWithPadding: Story = {

Item nested in immediate sibling

- + @@ -160,7 +160,7 @@ export const HeaderWithoutPadding: Story = {

No card or item used

- +

just a div, so bit-section-header has no padding

@@ -171,7 +171,7 @@ export const HeaderWithoutPadding: Story = {

Card nested in non-immediate sibling

- +
a div here diff --git a/libs/components/src/select/option.component.ts b/libs/components/src/select/option.component.ts index 39671b57221..ae75fe6514d 100644 --- a/libs/components/src/select/option.component.ts +++ b/libs/components/src/select/option.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Component, booleanAttribute, input } from "@angular/core"; import { MappedOptionComponent } from "./option"; diff --git a/libs/components/src/select/select.component.ts b/libs/components/src/select/select.component.ts index d16f09ee8a9..90b39e9c13a 100644 --- a/libs/components/src/select/select.component.ts +++ b/libs/components/src/select/select.component.ts @@ -1,6 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore - import { hasModifierKey } from "@angular/cdk/keycodes"; import { Component, @@ -10,7 +7,6 @@ import { Optional, QueryList, Self, - ViewChild, Output, EventEmitter, input, @@ -18,6 +14,7 @@ import { computed, model, signal, + viewChild, } from "@angular/core"; import { ControlValueAccessor, @@ -47,7 +44,7 @@ let nextId = 0; }, }) export class SelectComponent implements BitFormFieldControl, ControlValueAccessor { - @ViewChild(NgSelectComponent) select: NgSelectComponent; + readonly select = viewChild.required(NgSelectComponent); /** Optional: Options can be provided using an array input or using `bit-option` */ readonly items = model[] | undefined>(); @@ -55,13 +52,13 @@ export class SelectComponent implements BitFormFieldControl, ControlValueAcce readonly placeholder = input(this.i18nService.t("selectPlaceholder")); @Output() closed = new EventEmitter(); - protected selectedValue = signal(undefined); - selectedOption: Signal> = computed(() => + protected selectedValue = signal(undefined); + selectedOption: Signal | null | undefined> = computed(() => this.findSelectedOption(this.items(), this.selectedValue()), ); protected searchInputId = `bit-select-search-input-${nextId++}`; - private notifyOnChange?: (value: T) => void; + private notifyOnChange?: (value?: T | null) => void; private notifyOnTouched?: () => void; constructor( @@ -104,7 +101,7 @@ export class SelectComponent implements BitFormFieldControl, ControlValueAcce set disabled(value: any) { this._disabled = value != null && value !== false; } - private _disabled: boolean; + private _disabled?: boolean; /**Implemented as part of NG_VALUE_ACCESSOR */ writeValue(obj: T): void { @@ -112,7 +109,7 @@ export class SelectComponent implements BitFormFieldControl, ControlValueAcce } /**Implemented as part of NG_VALUE_ACCESSOR */ - registerOnChange(fn: (value: T) => void): void { + registerOnChange(fn: (value?: T | null) => void): void { this.notifyOnChange = fn; } @@ -151,11 +148,11 @@ export class SelectComponent implements BitFormFieldControl, ControlValueAcce get ariaDescribedBy() { return this._ariaDescribedBy; } - set ariaDescribedBy(value: string) { + set ariaDescribedBy(value: string | undefined) { this._ariaDescribedBy = value; - this.select?.searchInput.nativeElement.setAttribute("aria-describedby", value); + this.select()?.searchInput.nativeElement.setAttribute("aria-describedby", value ?? ""); } - private _ariaDescribedBy: string; + private _ariaDescribedBy?: string; /**Implemented as part of BitFormFieldControl */ get labelForId() { @@ -176,20 +173,24 @@ export class SelectComponent implements BitFormFieldControl, ControlValueAcce set required(value: any) { this._required = value != null && value !== false; } - private _required: boolean; + private _required?: boolean; /**Implemented as part of BitFormFieldControl */ get hasError() { - return this.ngControl?.status === "INVALID" && this.ngControl?.touched; + return !!(this.ngControl?.status === "INVALID" && this.ngControl?.touched); } /**Implemented as part of BitFormFieldControl */ get error(): [string, any] { - const key = Object.keys(this.ngControl?.errors)[0]; - return [key, this.ngControl?.errors[key]]; + const errors = this.ngControl?.errors ?? {}; + const key = Object.keys(errors)[0]; + return [key, errors[key]]; } - private findSelectedOption(items: Option[] | undefined, value: T): Option | undefined { + private findSelectedOption( + items: Option[] | undefined, + value: T | null | undefined, + ): Option | undefined { return items?.find((item) => item.value === value); } @@ -207,7 +208,7 @@ export class SelectComponent implements BitFormFieldControl, ControlValueAcce * Needs to be arrow function to retain `this` scope. */ protected onKeyDown = (event: KeyboardEvent) => { - if (this.select.isOpen && event.key === "Escape" && !hasModifierKey(event)) { + if (this.select().isOpen && event.key === "Escape" && !hasModifierKey(event)) { event.stopPropagation(); } diff --git a/libs/components/src/shared/button-like.abstraction.ts b/libs/components/src/shared/button-like.abstraction.ts index c7cb620bff0..63391743837 100644 --- a/libs/components/src/shared/button-like.abstraction.ts +++ b/libs/components/src/shared/button-like.abstraction.ts @@ -1,12 +1,10 @@ -// FIXME: Update this file to be type safe and remove this and next line import { ModelSignal } from "@angular/core"; -// @ts-strict-ignore export type ButtonType = "primary" | "secondary" | "danger" | "unstyled"; export type ButtonSize = "default" | "small"; export abstract class ButtonLikeAbstraction { - loading: ModelSignal; - disabled: ModelSignal; + abstract loading: ModelSignal; + abstract disabled: ModelSignal; } diff --git a/libs/components/src/shared/compact-mode.service.ts b/libs/components/src/shared/compact-mode.service.ts index 831add93676..e1ab7c36dd5 100644 --- a/libs/components/src/shared/compact-mode.service.ts +++ b/libs/components/src/shared/compact-mode.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Observable } from "rxjs"; /** Global config for the Bitwarden Design System */ @@ -9,5 +7,5 @@ export abstract class CompactModeService { * * Component authors can also hook into compact mode with the `bit-compact:` Tailwind variant. **/ - enabled$: Observable; + abstract enabled$: Observable; } diff --git a/libs/components/src/shared/focusable-element.ts b/libs/components/src/shared/focusable-element.ts index b67dd099dd4..4c6d8fd333f 100644 --- a/libs/components/src/shared/focusable-element.ts +++ b/libs/components/src/shared/focusable-element.ts @@ -1,10 +1,8 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore /** * Interface for implementing focusable components. * * Used by the `AutofocusDirective`. */ export abstract class FocusableElement { - getFocusTarget: () => HTMLElement | undefined; + abstract getFocusTarget(): HTMLElement | undefined; } diff --git a/libs/components/src/stories/kitchen-sink/components/dialog-virtual-scroll-block.component.ts b/libs/components/src/stories/kitchen-sink/components/dialog-virtual-scroll-block.component.ts index 9eb68d3f23f..7f6a6c42f32 100644 --- a/libs/components/src/stories/kitchen-sink/components/dialog-virtual-scroll-block.component.ts +++ b/libs/components/src/stories/kitchen-sink/components/dialog-virtual-scroll-block.component.ts @@ -36,7 +36,7 @@ import { TableDataSource, TableModule } from "../../../table"; 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 316dbf22d66..f92a14330d6 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 @@ -80,7 +80,13 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module"; - +
diff --git a/libs/components/src/stories/kitchen-sink/components/kitchen-sink-table.component.ts b/libs/components/src/stories/kitchen-sink/components/kitchen-sink-table.component.ts index 8765eae9960..302d9f6c0a8 100644 --- a/libs/components/src/stories/kitchen-sink/components/kitchen-sink-table.component.ts +++ b/libs/components/src/stories/kitchen-sink/components/kitchen-sink-table.component.ts @@ -23,6 +23,7 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module"; type="button" bitIconButton="bwi-ellipsis-v" [bitMenuTriggerFor]="menu1" + label="Options" > Anchor link @@ -40,6 +41,7 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module"; type="button" bitIconButton="bwi-ellipsis-v" [bitMenuTriggerFor]="menu2" + label="Options" > Anchor link diff --git a/libs/components/src/table/sortable.component.ts b/libs/components/src/table/sortable.component.ts index da47c7c01f8..6d5f72dd379 100644 --- a/libs/components/src/table/sortable.component.ts +++ b/libs/components/src/table/sortable.component.ts @@ -1,5 +1,3 @@ -// 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 } from "@angular/common"; import { Component, HostBinding, OnInit, input } from "@angular/core"; @@ -26,7 +24,7 @@ export class SortableComponent implements OnInit { /** * Mark the column as sortable and specify the key to sort by */ - readonly bitSortable = input(); + readonly bitSortable = input.required(); readonly default = input(false, { transform: (value: SortDirection | boolean | "") => { @@ -63,7 +61,7 @@ export class SortableComponent implements OnInit { if (!this.isActive) { return undefined; } - return this.sort.direction === "asc" ? "ascending" : "descending"; + return this.sort?.direction === "asc" ? "ascending" : "descending"; } protected setActive() { diff --git a/libs/components/src/table/table-data-source.ts b/libs/components/src/table/table-data-source.ts index 9a5d9ddf1f7..52e5d00b344 100644 --- a/libs/components/src/table/table-data-source.ts +++ b/libs/components/src/table/table-data-source.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { _isNumberValue } from "@angular/cdk/coercion"; import { DataSource } from "@angular/cdk/collections"; import { BehaviorSubject, combineLatest, map, Observable, Subscription } from "rxjs"; @@ -19,7 +17,7 @@ export type FilterFn = (data: T) => boolean; export class TableDataSource extends DataSource { private readonly _data: BehaviorSubject; private readonly _sort: BehaviorSubject; - private readonly _filter = new BehaviorSubject>(null); + private readonly _filter = new BehaviorSubject>(() => true); private readonly _renderData = new BehaviorSubject([]); private _renderChangesSubscription: Subscription | null = null; @@ -29,12 +27,12 @@ export class TableDataSource extends DataSource { * For example, a 'selectAll()' function would likely want to select the set of filtered data * shown to the user rather than all the data. */ - filteredData: T[]; + filteredData?: T[]; constructor() { super(); - this._data = new BehaviorSubject([]); - this._sort = new BehaviorSubject({ direction: "asc" }); + this._data = new BehaviorSubject([] as T[]); + this._sort = new BehaviorSubject({ direction: "asc" } as Sort); } get data() { diff --git a/libs/components/src/table/table-scroll.component.html b/libs/components/src/table/table-scroll.component.html index 2c4e06e6d22..99a5d200aca 100644 --- a/libs/components/src/table/table-scroll.component.html +++ b/libs/components/src/table/table-scroll.component.html @@ -13,7 +13,9 @@ - + diff --git a/libs/components/src/table/table-scroll.component.ts b/libs/components/src/table/table-scroll.component.ts index e30339393e7..2f9436baa0b 100644 --- a/libs/components/src/table/table-scroll.component.ts +++ b/libs/components/src/table/table-scroll.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll, @@ -9,7 +7,6 @@ import { CommonModule } from "@angular/common"; import { AfterContentChecked, Component, - ContentChild, OnDestroy, TemplateRef, Directive, @@ -18,6 +15,7 @@ import { ElementRef, TrackByFunction, input, + contentChild, } from "@angular/core"; import { ScrollLayoutDirective } from "../layout"; @@ -69,7 +67,7 @@ export class TableScrollComponent /** Optional trackBy function. */ readonly trackBy = input | undefined>(); - @ContentChild(BitRowDef) protected rowDef: BitRowDef; + protected readonly rowDef = contentChild(BitRowDef); /** * Height of the thead element (in pixels). @@ -81,7 +79,7 @@ export class TableScrollComponent /** * Observer for table header, applies padding on resize. */ - private headerObserver: ResizeObserver; + private headerObserver?: ResizeObserver; constructor( private zone: NgZone, diff --git a/libs/components/src/table/table.component.html b/libs/components/src/table/table.component.html index 6615d0cb42a..75d283e7bf3 100644 --- a/libs/components/src/table/table.component.html +++ b/libs/components/src/table/table.component.html @@ -6,7 +6,7 @@ diff --git a/libs/components/src/table/table.component.ts b/libs/components/src/table/table.component.ts index 187c4c8d6f2..7fdd0c40e91 100644 --- a/libs/components/src/table/table.component.ts +++ b/libs/components/src/table/table.component.ts @@ -1,15 +1,13 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { isDataSource } from "@angular/cdk/collections"; import { CommonModule } from "@angular/common"; import { AfterContentChecked, Component, - ContentChild, Directive, OnDestroy, TemplateRef, input, + contentChild, } from "@angular/core"; import { Observable } from "rxjs"; @@ -32,9 +30,9 @@ export class TableComponent implements OnDestroy, AfterContentChecked { readonly dataSource = input>(); readonly layout = input<"auto" | "fixed">("auto"); - @ContentChild(TableBodyDirective) templateVariable: TableBodyDirective; + readonly templateVariable = contentChild(TableBodyDirective); - protected rows$: Observable; + protected rows$?: Observable; private _initialized = false; diff --git a/libs/components/src/tabs/shared/tab-list-item.directive.ts b/libs/components/src/tabs/shared/tab-list-item.directive.ts index 0c662980442..c8b96141ecd 100644 --- a/libs/components/src/tabs/shared/tab-list-item.directive.ts +++ b/libs/components/src/tabs/shared/tab-list-item.directive.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { FocusableOption } from "@angular/cdk/a11y"; import { Directive, ElementRef, HostBinding, Input, input } from "@angular/core"; @@ -15,7 +13,7 @@ export class TabListItemDirective implements FocusableOption { // TODO: Skipped for signal migration because: // This input overrides a field from a superclass, while the superclass field // is not migrated. - @Input() disabled: boolean; + @Input() disabled = false; @HostBinding("attr.disabled") get disabledAttr() { diff --git a/libs/components/src/tabs/tab-group/tab-body.component.ts b/libs/components/src/tabs/tab-group/tab-body.component.ts index e200e55220d..2dec4ca180d 100644 --- a/libs/components/src/tabs/tab-group/tab-body.component.ts +++ b/libs/components/src/tabs/tab-group/tab-body.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { TemplatePortal, CdkPortalOutlet } from "@angular/cdk/portal"; import { Component, effect, HostBinding, input } from "@angular/core"; @@ -9,7 +7,7 @@ import { Component, effect, HostBinding, input } from "@angular/core"; imports: [CdkPortalOutlet], }) export class TabBodyComponent { - private _firstRender: boolean; + private _firstRender = false; readonly content = input(); readonly preserveContent = input(false); 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 7e1e9305600..22214b1b3ce 100644 --- a/libs/components/src/tabs/tab-group/tab-group.component.html +++ b/libs/components/src/tabs/tab-group/tab-group.component.html @@ -5,7 +5,7 @@ [attr.aria-label]="label()" (keydown)="keyManager.onKeydown($event)" > - @for (tab of tabs; track tab; let i = $index) { + @for (tab of tabs(); track tab; let i = $index) {
diff --git a/libs/components/src/toast/toast.service.ts b/libs/components/src/toast/toast.service.ts index f55fb3ada83..a2fc1da7ad3 100644 --- a/libs/components/src/toast/toast.service.ts +++ b/libs/components/src/toast/toast.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Injectable } from "@angular/core"; import { IndividualConfig, ToastrService } from "ngx-toastr"; @@ -36,7 +34,7 @@ export class ToastService { : calculateToastTimeout(options.message), }; - this.toastrService.show(null, options.title, toastrConfig); + this.toastrService.show(undefined, options.title, toastrConfig); } /** diff --git a/libs/components/src/toggle-group/toggle.component.ts b/libs/components/src/toggle-group/toggle.component.ts index 828386f84ed..0301fe17979 100644 --- a/libs/components/src/toggle-group/toggle.component.ts +++ b/libs/components/src/toggle-group/toggle.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { NgClass } from "@angular/common"; import { AfterContentChecked, @@ -8,8 +6,8 @@ import { ElementRef, HostBinding, signal, - ViewChild, input, + viewChild, } from "@angular/core"; import { ToggleGroupComponent } from "./toggle-group.component"; @@ -24,9 +22,9 @@ let nextId = 0; export class ToggleComponent implements AfterContentChecked, AfterViewInit { id = nextId++; - readonly value = input(); - @ViewChild("labelContent") labelContent: ElementRef; - @ViewChild("bitBadgeContainer") bitBadgeContainer: ElementRef; + readonly value = input.required(); + readonly labelContent = viewChild>("labelContent"); + readonly bitBadgeContainer = viewChild>("bitBadgeContainer"); constructor(private groupComponent: ToggleGroupComponent) {} @@ -34,7 +32,7 @@ export class ToggleComponent implements AfterContentChecked, AfterViewIn @HostBinding("class") classList = ["tw-group/toggle", "tw-flex", "tw-min-w-16"]; protected bitBadgeContainerHasChidlren = signal(false); - protected labelTitle = signal(null); + protected labelTitle = signal(null); get name() { return this.groupComponent.name; @@ -100,12 +98,12 @@ export class ToggleComponent implements AfterContentChecked, AfterViewIn ngAfterContentChecked() { this.bitBadgeContainerHasChidlren.set( - this.bitBadgeContainer?.nativeElement.childElementCount > 0, + (this.bitBadgeContainer()?.nativeElement.childElementCount ?? 0) > 0, ); } ngAfterViewInit() { - const labelText = this.labelContent?.nativeElement.innerText; + const labelText = this.labelContent()?.nativeElement.innerText; if (labelText) { this.labelTitle.set(labelText); } diff --git a/libs/components/src/typography/typography.directive.ts b/libs/components/src/typography/typography.directive.ts index 76451d3c82f..3a507d3fc4b 100644 --- a/libs/components/src/typography/typography.directive.ts +++ b/libs/components/src/typography/typography.directive.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { booleanAttribute, Directive, HostBinding, input } from "@angular/core"; type TypographyType = "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "body1" | "body2" | "helper"; @@ -32,7 +30,7 @@ const margins: Record = { selector: "[bitTypography]", }) export class TypographyDirective { - readonly bitTypography = input(); + readonly bitTypography = input.required(); readonly noMargin = input(false, { transform: booleanAttribute }); diff --git a/libs/components/src/utils/i18n-mock.service.ts b/libs/components/src/utils/i18n-mock.service.ts index 434da2255b3..bdc595f12cb 100644 --- a/libs/components/src/utils/i18n-mock.service.ts +++ b/libs/components/src/utils/i18n-mock.service.ts @@ -1,18 +1,9 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Observable } from "rxjs"; - import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -export class I18nMockService implements I18nService { - userSetLocale$: Observable; - locale$: Observable; - supportedTranslationLocales: string[]; - translationLocale: string; - collator: Intl.Collator; - localeNames: Map; - - constructor(private lookupTable: Record string)>) {} +export class I18nMockService implements Pick { + constructor( + private lookupTable: Record string)>, + ) {} t(id: string, p1?: string, p2?: string, p3?: string) { let value = this.lookupTable[id]; diff --git a/libs/eslint/components/index.mjs b/libs/eslint/components/index.mjs new file mode 100644 index 00000000000..0a9b1d1481f --- /dev/null +++ b/libs/eslint/components/index.mjs @@ -0,0 +1,3 @@ +import requireLabelOnBiticonbutton from "./require-label-on-biticonbutton.mjs"; + +export default { rules: { "require-label-on-biticonbutton": requireLabelOnBiticonbutton } }; diff --git a/libs/eslint/components/require-label-on-biticonbutton.mjs b/libs/eslint/components/require-label-on-biticonbutton.mjs new file mode 100644 index 00000000000..be177c65017 --- /dev/null +++ b/libs/eslint/components/require-label-on-biticonbutton.mjs @@ -0,0 +1,51 @@ +export const errorMessage = + "Elements with 'bitIconButton' must also have a 'label' attribute for accessibility."; + +export default { + meta: { + type: "problem", + docs: { + description: + "Require a label attribute on elements with bitIconButton, except when ignored attributes are present", + category: "Best Practices", + recommended: false, + }, + schema: [ + { + type: "object", + properties: { + ignoreIfHas: { + type: "array", + items: { type: "string" }, + description: "Attributes that, if present, will skip the label requirement.", + }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const [{ ignoreIfHas = [] } = {}] = context.options; + + return { + Element(node) { + const allAttrNames = [ + ...(node.attributes?.map((attr) => attr.name) ?? []), + ...(node.inputs?.map((input) => input.name) ?? []), + ...(node.templateAttrs?.map((attr) => attr.name) ?? []), + ]; + + const hasBitIconButton = allAttrNames.includes("bitIconButton"); + const hasLabel = allAttrNames.includes("label"); + const shouldIgnore = ignoreIfHas.some((attr) => allAttrNames.includes(attr)); + + if (hasBitIconButton && !shouldIgnore && !hasLabel) { + context.report({ + node, + message: errorMessage, + }); + } + }, + }; + }, +}; diff --git a/libs/key-management/src/key.service.spec.ts b/libs/key-management/src/key.service.spec.ts index 04cf6289879..4d872e2cd5c 100644 --- a/libs/key-management/src/key.service.spec.ts +++ b/libs/key-management/src/key.service.spec.ts @@ -2,6 +2,7 @@ import { mock } from "jest-mock-extended"; import { BehaviorSubject, bufferCount, firstValueFrom, lastValueFrom, of, take } from "rxjs"; import { EncryptedOrganizationKeyData } from "@bitwarden/common/admin-console/models/data/encrypted-organization-key.data"; +import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { @@ -12,7 +13,6 @@ import { FakeMasterPasswordService } from "@bitwarden/common/key-management/mast import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { VaultTimeoutStringType } from "@bitwarden/common/key-management/vault-timeout"; import { VAULT_TIMEOUT } from "@bitwarden/common/key-management/vault-timeout/services/vault-timeout-settings.state"; -import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; 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"; diff --git a/libs/key-management/src/key.service.ts b/libs/key-management/src/key.service.ts index f55a8211dbe..92bee383a0b 100644 --- a/libs/key-management/src/key.service.ts +++ b/libs/key-management/src/key.service.ts @@ -19,6 +19,7 @@ import { ProfileOrganizationResponse } from "@bitwarden/common/admin-console/mod import { ProfileProviderOrganizationResponse } from "@bitwarden/common/admin-console/models/response/profile-provider-organization.response"; import { ProfileProviderResponse } from "@bitwarden/common/admin-console/models/response/profile-provider.response"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { @@ -29,7 +30,6 @@ import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key- import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { VaultTimeoutStringType } from "@bitwarden/common/key-management/vault-timeout"; import { VAULT_TIMEOUT } from "@bitwarden/common/key-management/vault-timeout/services/vault-timeout-settings.state"; -import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; 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"; @@ -732,7 +732,7 @@ export class DefaultKeyService implements KeyServiceAbstraction { protected async getKeyFromStorage( keySuffix: KeySuffixOptions, - userId?: UserId, + userId: UserId, ): Promise { if (keySuffix === KeySuffixOptions.Auto) { const userKey = await this.stateService.getUserKeyAutoUnlock({ userId: userId }); diff --git a/libs/state/src/index.ts b/libs/state/src/index.ts index d74e7fc137d..71563ecb764 100644 --- a/libs/state/src/index.ts +++ b/libs/state/src/index.ts @@ -2,3 +2,4 @@ export * from "./core"; export * from "./state-migrations"; export * from "./types/state"; +export * from "./legacy"; diff --git a/libs/state/src/legacy/default-state.service.ts b/libs/state/src/legacy/default-state.service.ts new file mode 100644 index 00000000000..b1c5ddb3a0b --- /dev/null +++ b/libs/state/src/legacy/default-state.service.ts @@ -0,0 +1,107 @@ +import { firstValueFrom } from "rxjs"; + +import { StorageService } from "@bitwarden/storage-core"; +import { UserId } from "@bitwarden/user-core"; + +import { ActiveUserAccessor } from "../core"; + +import { GlobalState } from "./global-state"; +import { RequiredUserId, StateService } from "./state.service"; + +const keys = { + global: "global", +}; + +const partialKeys = { + userAutoKey: "_user_auto", + userBiometricKey: "_user_biometric", +}; + +const DDG_SHARED_KEY = "DuckDuckGoSharedKey"; + +export class DefaultStateService implements StateService { + constructor( + private readonly storageService: StorageService, + private readonly secureStorageService: StorageService, + private readonly activeUserAccessor: ActiveUserAccessor, + ) {} + + async clean(options: RequiredUserId): Promise { + await this.setUserKeyAutoUnlock(null, options); + await this.clearUserKeyBiometric(options.userId); + } + + /** + * user key when using the "never" option of vault timeout + */ + async getUserKeyAutoUnlock(options: RequiredUserId): Promise { + if (options.userId == null) { + return null; + } + return await this.secureStorageService.get( + `${options.userId}${partialKeys.userAutoKey}`, + { + userId: options.userId, + keySuffix: "auto", + }, + ); + } + + /** + * user key when using the "never" option of vault timeout + */ + async setUserKeyAutoUnlock(value: string | null, options: RequiredUserId): Promise { + if (options.userId == null) { + return; + } + await this.saveSecureStorageKey(partialKeys.userAutoKey, value, options.userId, "auto"); + } + + private async clearUserKeyBiometric(userId: UserId): Promise { + if (userId == null) { + return; + } + await this.saveSecureStorageKey(partialKeys.userBiometricKey, null, userId, "biometric"); + } + + async getDuckDuckGoSharedKey(): Promise { + const userId = await this.getActiveUserIdFromStorage(); + if (userId == null) { + return null; + } + return await this.secureStorageService.get(DDG_SHARED_KEY); + } + + async setDuckDuckGoSharedKey(value: string): Promise { + const userId = await this.getActiveUserIdFromStorage(); + if (userId == null) { + return; + } + value == null + ? await this.secureStorageService.remove(DDG_SHARED_KEY) + : await this.secureStorageService.save(DDG_SHARED_KEY, value); + } + + async setEnableDuckDuckGoBrowserIntegration(value: boolean): Promise { + const globals = (await this.storageService.get(keys.global)) ?? new GlobalState(); + globals.enableDuckDuckGoBrowserIntegration = value; + await this.storageService.save(keys.global, globals); + } + + private async getActiveUserIdFromStorage(): Promise { + return await firstValueFrom(this.activeUserAccessor.activeUserId$); + } + + private async saveSecureStorageKey( + key: string, + value: string | null, + userId: UserId, + keySuffix: string, + ) { + return value == null + ? await this.secureStorageService.remove(`${userId}${key}`, { keySuffix: keySuffix }) + : await this.secureStorageService.save(`${userId}${key}`, value, { + keySuffix: keySuffix, + }); + } +} diff --git a/libs/common/src/platform/models/domain/global-state.ts b/libs/state/src/legacy/global-state.ts similarity index 100% rename from libs/common/src/platform/models/domain/global-state.ts rename to libs/state/src/legacy/global-state.ts diff --git a/libs/state/src/legacy/index.ts b/libs/state/src/legacy/index.ts new file mode 100644 index 00000000000..d25d4d4616a --- /dev/null +++ b/libs/state/src/legacy/index.ts @@ -0,0 +1,2 @@ +export { StateService } from "./state.service"; +export { DefaultStateService } from "./default-state.service"; diff --git a/libs/state/src/legacy/state.service.ts b/libs/state/src/legacy/state.service.ts new file mode 100644 index 00000000000..dd07a975895 --- /dev/null +++ b/libs/state/src/legacy/state.service.ts @@ -0,0 +1,25 @@ +import { UserId } from "@bitwarden/user-core"; + +export type RequiredUserId = { userId: UserId }; + +/** + * This class exists for various legacy reasons, there are likely better things to use than this service. + */ +export abstract class StateService { + abstract clean(options: RequiredUserId): Promise; + + /** + * Gets the user's auto key + */ + abstract getUserKeyAutoUnlock(options: RequiredUserId): Promise; + /** + * Sets the user's auto key + */ + abstract setUserKeyAutoUnlock(value: string | null, options: RequiredUserId): Promise; + /** + * @deprecated For backwards compatible purposes only, use DesktopAutofillSettingsService + */ + abstract setEnableDuckDuckGoBrowserIntegration(value: boolean): Promise; + abstract getDuckDuckGoSharedKey(): Promise; + abstract setDuckDuckGoSharedKey(value: string): Promise; +} diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html index 398085c135c..b33b01d3b13 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html @@ -89,6 +89,7 @@ appStopClick bitSuffix (click)="generatePassword()" + [label]="'generatePassword' | i18n" > {{ "exportPasswordDescription" | i18n }} diff --git a/libs/tools/generator/components/src/credential-generator-history.component.html b/libs/tools/generator/components/src/credential-generator-history.component.html index 05199763a15..1f2f3d99e00 100644 --- a/libs/tools/generator/components/src/credential-generator-history.component.html +++ b/libs/tools/generator/components/src/credential-generator-history.component.html @@ -12,7 +12,7 @@ bitIconButton="bwi-clone" [appCopyClick]="credential.credential" [valueLabel]="getGeneratedValueText(credential)" - [appA11yTitle]="getCopyText(credential)" + [label]="getCopyText(credential)" showToast > {{ getCopyText(credential) }} diff --git a/libs/tools/generator/components/src/credential-generator.component.html b/libs/tools/generator/components/src/credential-generator.component.html index 3f9813f4384..124de1e3c45 100644 --- a/libs/tools/generator/components/src/credential-generator.component.html +++ b/libs/tools/generator/components/src/credential-generator.component.html @@ -23,7 +23,7 @@ bitIconButton="bwi-generate" buttonType="main" (click)="generate(USER_REQUEST)" - [appA11yTitle]="credentialTypeGenerateLabel$ | async" + [label]="credentialTypeGenerateLabel$ | async" [disabled]="!(algorithm$ | async)" > {{ credentialTypeGenerateLabel$ | async }} @@ -33,7 +33,7 @@ bitIconButton="bwi-clone" buttonType="main" showToast - [appA11yTitle]="credentialTypeCopyLabel$ | async" + [label]="credentialTypeCopyLabel$ | async" [appCopyClick]="value$ | async" [valueLabel]="credentialTypeLabel$ | async" [disabled]="!(algorithm$ | async)" diff --git a/libs/tools/generator/components/src/password-generator.component.html b/libs/tools/generator/components/src/password-generator.component.html index ce0768fe128..9995613685b 100644 --- a/libs/tools/generator/components/src/password-generator.component.html +++ b/libs/tools/generator/components/src/password-generator.component.html @@ -20,7 +20,7 @@ bitIconButton="bwi-generate" buttonType="main" (click)="generate(USER_REQUEST)" - [appA11yTitle]="credentialTypeGenerateLabel$ | async" + [label]="credentialTypeGenerateLabel$ | async" [disabled]="!(algorithm$ | async)" > {{ credentialTypeGenerateLabel$ | async }} @@ -30,7 +30,7 @@ bitIconButton="bwi-clone" buttonType="main" showToast - [appA11yTitle]="credentialTypeCopyLabel$ | async" + [label]="credentialTypeCopyLabel$ | async" [appCopyClick]="value$ | async" [valueLabel]="credentialTypeLabel$ | async" [disabled]="!(algorithm$ | async)" diff --git a/libs/tools/generator/components/src/username-generator.component.html b/libs/tools/generator/components/src/username-generator.component.html index 51b998f1d56..0f3182118a1 100644 --- a/libs/tools/generator/components/src/username-generator.component.html +++ b/libs/tools/generator/components/src/username-generator.component.html @@ -8,7 +8,7 @@ bitIconButton="bwi-generate" buttonType="main" (click)="generate(USER_REQUEST)" - [appA11yTitle]="credentialTypeGenerateLabel$ | async" + [label]="credentialTypeGenerateLabel$ | async" [disabled]="!(algorithm$ | async)" > {{ credentialTypeGenerateLabel$ | async }} @@ -18,7 +18,7 @@ bitIconButton="bwi-clone" buttonType="main" showToast - [appA11yTitle]="credentialTypeCopyLabel$ | async" + [label]="credentialTypeCopyLabel$ | async" [appCopyClick]="value$ | async" [valueLabel]="credentialTypeLabel$ | async" [disabled]="!(algorithm$ | async)" diff --git a/libs/tools/generator/extensions/legacy/src/legacy-password-generation.service.ts b/libs/tools/generator/extensions/legacy/src/legacy-password-generation.service.ts index 41dcb94a001..02e86ad8fe0 100644 --- a/libs/tools/generator/extensions/legacy/src/legacy-password-generation.service.ts +++ b/libs/tools/generator/extensions/legacy/src/legacy-password-generation.service.ts @@ -345,7 +345,8 @@ export class LegacyPasswordGenerationService implements PasswordGenerationServic timeout({ // timeout after 1 second each: 1000, - with() { + // TODO(PM-22309): Typescript 5.8 update, confirm type + with(): any[] { return []; }, }), @@ -370,7 +371,8 @@ export class LegacyPasswordGenerationService implements PasswordGenerationServic timeout({ // timeout after 1 second each: 1000, - with() { + // TODO(PM-22309): Typescript 5.8 update, confirm type + with(): any[] { return []; }, }), diff --git a/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.html b/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.html index d3f3ebedf49..b0edbe12892 100644 --- a/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.html +++ b/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.html @@ -28,7 +28,7 @@ slot="end" bitIconButton="bwi-trash" [bitAction]="deleteSend" - appA11yTitle="{{ 'delete' | i18n }}" + label="{{ 'delete' | i18n }}" >
diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html index b9edf8eebcc..a271788b0ef 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html @@ -26,7 +26,7 @@ type="button" bitIconButton="bwi-generate" bitSuffix - [appA11yTitle]="'generatePassword' | i18n" + [label]="'generatePassword' | i18n" [disabled]="!config.areSendsAllowed" (click)="generatePassword()" data-testid="generate-password" @@ -36,7 +36,7 @@ type="button" bitIconButton="bwi-clone" bitSuffix - [appA11yTitle]="'copyPassword' | i18n" + [label]="'copyPassword' | i18n" [disabled]="!config.areSendsAllowed || !sendOptionsForm.get('password').value" [valueLabel]="'password' | i18n" [appCopyClick]="sendOptionsForm.get('password').value" @@ -50,7 +50,7 @@ type="button" buttonType="danger" bitIconButton="bwi-minus-circle" - [appA11yTitle]="'removePassword' | i18n" + [label]="'removePassword' | i18n" [bitAction]="removePassword" showToast > diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html index 214c978ad48..e650ca3a5df 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html @@ -30,7 +30,7 @@ showToast bitIconButton="bwi-clone" [appCopyClick]="sendLink" - [appA11yTitle]="'copySendLink' | i18n" + [label]="'copySendLink' | i18n" > diff --git a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html index 94ebfc3e5e6..3442375315a 100644 --- a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html +++ b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html @@ -48,7 +48,7 @@ size="small" type="button" (click)="copySendLink(send)" - appA11yTitle="{{ 'copyLink' | i18n }} - {{ send.name }}" + label="{{ 'copyLink' | i18n }} - {{ send.name }}" > @@ -57,7 +57,7 @@ size="small" type="button" (click)="deleteSend(send)" - appA11yTitle="{{ 'delete' | i18n }} - {{ send.name }}" + label="{{ 'delete' | i18n }} - {{ send.name }}" > diff --git a/libs/vault/src/cipher-form/components/attachments/delete-attachment/delete-attachment.component.html b/libs/vault/src/cipher-form/components/attachments/delete-attachment/delete-attachment.component.html index efbc1a0503c..71354a7c221 100644 --- a/libs/vault/src/cipher-form/components/attachments/delete-attachment/delete-attachment.component.html +++ b/libs/vault/src/cipher-form/components/attachments/delete-attachment/delete-attachment.component.html @@ -4,6 +4,6 @@ size="small" type="button" class="tw-border-transparent" - [appA11yTitle]="'deleteAttachmentName' | i18n: attachment.fileName" + [label]="'deleteAttachmentName' | i18n: attachment.fileName" [bitAction]="delete" > diff --git a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.html b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.html index 2e88a68a0d4..2390512c92d 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.html +++ b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.html @@ -8,7 +8,7 @@ type="button" [bitIconButton]="showMatchDetection ? 'bwi-cog-f' : 'bwi-cog'" bitSuffix - [appA11yTitle]="toggleTitle" + [label]="toggleTitle" (click)="toggleMatchDetection()" data-testid="toggle-match-detection-button" > @@ -17,7 +17,7 @@ bitIconButton="bwi-minus-circle" buttonType="danger" bitSuffix - [appA11yTitle]="'deleteWebsite' | i18n" + [label]="'deleteWebsite' | i18n" *ngIf="canRemove" (click)="removeUri()" data-testid="remove-uri-button" @@ -29,7 +29,7 @@ bitIconButton="bwi-drag-and-drop" class="!tw-py-0 !tw-px-1" cdkDragHandle - [appA11yTitle]="'reorderToggleButton' | i18n: uriLabel" + [label]="'reorderToggleButton' | i18n: uriLabel" (keydown)="handleKeydown($event)" data-testid="reorder-toggle-button" *ngIf="canReorder" diff --git a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts index 4287d5b96c8..8b6b6a6490b 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts @@ -136,7 +136,7 @@ export class UriOptionComponent implements ControlValueAccessor { protected toggleMatchDetection() { this.showMatchDetection = !this.showMatchDetection; if (this.showMatchDetection) { - setTimeout(() => this.matchDetectionSelect?.select?.focus(), 0); + setTimeout(() => this.matchDetectionSelect?.select()?.focus(), 0); } } diff --git a/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.html b/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.html index f379f466b4a..4c0d567ad75 100644 --- a/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.html +++ b/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.html @@ -40,7 +40,7 @@ buttonType="danger" class="tw-ml-auto" bitIconButton="bwi-trash" - [appA11yTitle]="'deleteCustomField' | i18n: customFieldForm.value.label" + [label]="'deleteCustomField' | i18n: customFieldForm.value.label" (click)="removeField()" > diff --git a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html index 98cc6489bbd..c47f332375b 100644 --- a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html +++ b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html @@ -89,7 +89,7 @@ diff --git a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.html b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.html index b0c501c53ed..7fb19d3c4b2 100644 --- a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.html +++ b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.html @@ -15,7 +15,7 @@ bitSuffix *ngIf="loginDetailsForm.controls.username.enabled" data-testid="generate-username-button" - [appA11yTitle]="'generateUsername' | i18n" + [label]="'generateUsername' | i18n" [bitAction]="generateUsername" > @@ -47,7 +47,7 @@ loginDetailsForm.controls.password.value?.length > 0 " data-testid="check-password-button" - [appA11yTitle]="'checkPassword' | i18n" + [label]="'checkPassword' | i18n" [bitAction]="checkPassword" > @@ -86,7 +86,7 @@ *ngIf="loginDetailsForm.enabled && viewHiddenFields" [bitAction]="removePasskey" data-testid="remove-passkey-button" - [appA11yTitle]="'removePasskey' | i18n" + [label]="'removePasskey' | i18n" > @@ -125,7 +125,7 @@ *ngIf="canCaptureTotp" data-testid="capture-totp-button" [bitAction]="captureTotp" - [appA11yTitle]="'totpCapture' | i18n" + [label]="'totpCapture' | i18n" > diff --git a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.html b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.html index de528267db0..ec9d715ff19 100644 --- a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.html +++ b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.html @@ -21,7 +21,7 @@ bitSuffix data-testid="import-privateKey" *ngIf="showImport" - appA11yTitle="{{ 'importSshKeyFromClipboard' | i18n }}" + label="{{ 'importSshKeyFromClipboard' | i18n }}" (click)="importSshKeyFromClipboard()" > diff --git a/libs/vault/src/cipher-view/additional-options/additional-options.component.html b/libs/vault/src/cipher-view/additional-options/additional-options.component.html index aa6d339dcd7..a53bfd239ca 100644 --- a/libs/vault/src/cipher-view/additional-options/additional-options.component.html +++ b/libs/vault/src/cipher-view/additional-options/additional-options.component.html @@ -14,7 +14,7 @@ [appCopyClick]="notes" showToast [valueLabel]="'note' | i18n" - [appA11yTitle]="'copyNotes' | i18n" + [label]="'copyNotes' | i18n" > diff --git a/libs/vault/src/cipher-view/autofill-options/autofill-options-view.component.html b/libs/vault/src/cipher-view/autofill-options/autofill-options-view.component.html index 1e17886f50b..1e8f7173167 100644 --- a/libs/vault/src/cipher-view/autofill-options/autofill-options-view.component.html +++ b/libs/vault/src/cipher-view/autofill-options/autofill-options-view.component.html @@ -26,7 +26,7 @@ type="button" (click)="openWebsite(login.launchUri)" data-testid="launch-website" - [attr.aria-label]="('launch' | i18n) + ' ' + login.hostOrUri" + [label]="('launch' | i18n) + ' ' + login.hostOrUri" > diff --git a/libs/vault/src/cipher-view/card-details/card-details-view.component.html b/libs/vault/src/cipher-view/card-details/card-details-view.component.html index 9d2fa45ba9e..ef023b69079 100644 --- a/libs/vault/src/cipher-view/card-details/card-details-view.component.html +++ b/libs/vault/src/cipher-view/card-details/card-details-view.component.html @@ -43,7 +43,7 @@ [appCopyClick]="card.number" showToast [valueLabel]="'number' | i18n" - [appA11yTitle]="'copyNumber' | i18n" + [label]="'copyNumber' | i18n" data-testid="copy-number" > @@ -87,7 +87,7 @@ [appCopyClick]="card.code" showToast [valueLabel]="'securityCode' | i18n" - [appA11yTitle]="'copySecurityCode' | i18n" + [label]="'copySecurityCode' | i18n" data-testid="copy-code" (click)="logCardEvent(true, EventType.Cipher_ClientCopiedCardCode)" > diff --git a/libs/vault/src/cipher-view/custom-fields/custom-fields-v2.component.html b/libs/vault/src/cipher-view/custom-fields/custom-fields-v2.component.html index 7c60d35965f..bc59c9058e2 100644 --- a/libs/vault/src/cipher-view/custom-fields/custom-fields-v2.component.html +++ b/libs/vault/src/cipher-view/custom-fields/custom-fields-v2.component.html @@ -25,7 +25,7 @@ [appCopyClick]="field.value" showToast [valueLabel]="field.name" - [appA11yTitle]="'copyCustomField' | i18n: field.name" + [label]="'copyCustomField' | i18n: field.name" data-testid="copy-custom-field" > @@ -57,7 +57,7 @@ bitSuffix type="button" data-testid="toggle-hidden-field-value-count" - [appA11yTitle]=" + [label]=" (showHiddenValueCountFields.includes(i) ? 'hideCharacterCount' : 'showCharacterCount') | i18n " @@ -81,7 +81,7 @@ showToast [valueLabel]="field.name" *ngIf="canViewPassword" - [appA11yTitle]="'copyCustomField' | i18n: field.name" + [label]="'copyCustomField' | i18n: field.name" (click)="logCopyEvent()" > diff --git a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html index 256aec34b50..53fcab70506 100644 --- a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html +++ b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html @@ -23,7 +23,7 @@ [appCopyClick]="cipher.login.username" [valueLabel]="'username' | i18n" showToast - [appA11yTitle]="'copyUsername' | i18n" + [label]="'copyUsername' | i18n" data-testid="copy-username" > @@ -36,6 +36,7 @@ [ngClass]="{ 'tw-hidden': passwordRevealed }" readonly bitInput + #passwordInput type="password" [value]="cipher.login.password" aria-readonly="true" @@ -62,7 +63,7 @@ bitSuffix type="button" data-testid="toggle-password-count" - [appA11yTitle]="(showPasswordCount ? 'hideCharacterCount' : 'showCharacterCount') | i18n" + [label]="(showPasswordCount ? 'hideCharacterCount' : 'showCharacterCount') | i18n" [attr.aria-expanded]="showPasswordCount" appStopClick (click)="togglePasswordCount()" @@ -85,7 +86,7 @@ [appCopyClick]="cipher.login.password" [valueLabel]="'password' | i18n" showToast - [appA11yTitle]="'copyPassword' | i18n" + [label]="'copyPassword' | i18n" data-testid="copy-password" (click)="logCopyEvent()" > @@ -157,7 +158,7 @@ [appCopyClick]="totpCodeCopyObj?.totpCode" [valueLabel]="'verificationCodeTotp' | i18n" showToast - [appA11yTitle]="'copyVerificationCode' | i18n" + [label]="'copyVerificationCode' | i18n" data-testid="copy-totp" [disabled]="!(isPremium$ | async)" class="disabled:tw-cursor-default" diff --git a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.ts b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.ts index 5eeb3f9e8f3..9a40caf031b 100644 --- a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.ts +++ b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.ts @@ -3,12 +3,14 @@ import { CommonModule, DatePipe } from "@angular/common"; import { Component, + ElementRef, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges, + ViewChild, } from "@angular/core"; import { Observable, switchMap } from "rxjs"; @@ -61,6 +63,8 @@ export class LoginCredentialsViewComponent implements OnChanges { @Input() activeUserId: UserId; @Input() hadPendingChangePasswordTask: boolean; @Output() handleChangePassword = new EventEmitter(); + @ViewChild("passwordInput") + private passwordInput!: ElementRef; isPremium$: Observable = this.accountService.activeAccount$.pipe( switchMap((account) => @@ -92,6 +96,10 @@ export class LoginCredentialsViewComponent implements OnChanges { ngOnChanges(changes: SimpleChanges): void { if (changes["cipher"]) { + if (this.passwordInput?.nativeElement) { + // Reset password input type in case it's been toggled + this.passwordInput.nativeElement.type = "password"; + } this.passwordRevealed = false; this.showPasswordCount = false; } diff --git a/libs/vault/src/cipher-view/sshkey-sections/sshkey-view.component.html b/libs/vault/src/cipher-view/sshkey-sections/sshkey-view.component.html index e74c0b06818..555f59fd22f 100644 --- a/libs/vault/src/cipher-view/sshkey-sections/sshkey-view.component.html +++ b/libs/vault/src/cipher-view/sshkey-sections/sshkey-view.component.html @@ -27,7 +27,7 @@ type="button" [appCopyClick]="sshKey.privateKey" showToast - [appA11yTitle]="'copyValue' | i18n" + [label]="'copyValue' | i18n" > @@ -45,7 +45,7 @@ type="button" [appCopyClick]="sshKey.publicKey" showToast - [appA11yTitle]="'copyValue' | i18n" + [label]="'copyValue' | i18n" > @@ -63,7 +63,7 @@ type="button" [appCopyClick]="sshKey.keyFingerprint" showToast - [appA11yTitle]="'copyValue' | i18n" + [label]="'copyValue' | i18n" > diff --git a/libs/vault/src/cipher-view/view-identity-sections/view-identity-sections.component.html b/libs/vault/src/cipher-view/view-identity-sections/view-identity-sections.component.html index 1b0a1f48f05..f3154994bbf 100644 --- a/libs/vault/src/cipher-view/view-identity-sections/view-identity-sections.component.html +++ b/libs/vault/src/cipher-view/view-identity-sections/view-identity-sections.component.html @@ -17,7 +17,7 @@ type="button" bitIconButton="bwi-clone" bitSuffix - [appA11yTitle]="'copyName' | i18n" + [label]="'copyName' | i18n" [appCopyClick]="cipher.identity.fullName" showToast [valueLabel]="'name' | i18n" @@ -37,7 +37,7 @@ type="button" bitIconButton="bwi-clone" bitSuffix - [appA11yTitle]="'copyUsername' | i18n" + [label]="'copyUsername' | i18n" [appCopyClick]="cipher.identity.username" showToast [valueLabel]="'username' | i18n" @@ -56,7 +56,7 @@ type="button" bitIconButton="bwi-clone" bitSuffix - [appA11yTitle]="'copyCompany' | i18n" + [label]="'copyCompany' | i18n" [appCopyClick]="cipher.identity.company" showToast [valueLabel]="'company' | i18n" @@ -93,7 +93,7 @@ type="button" bitIconButton="bwi-clone" bitSuffix - [appA11yTitle]="'copySSN' | i18n" + [label]="'copySSN' | i18n" [appCopyClick]="cipher.identity.ssn" showToast [valueLabel]="'ssn' | i18n" @@ -123,7 +123,7 @@ type="button" bitIconButton="bwi-clone" bitSuffix - [appA11yTitle]="'copyPassportNumber' | i18n" + [label]="'copyPassportNumber' | i18n" [appCopyClick]="cipher.identity.passportNumber" showToast [valueLabel]="'passportNumber' | i18n" @@ -145,7 +145,7 @@ type="button" bitIconButton="bwi-clone" bitSuffix - [appA11yTitle]="'copyLicenseNumber' | i18n" + [label]="'copyLicenseNumber' | i18n" [appCopyClick]="cipher.identity.licenseNumber" showToast [valueLabel]="'licenseNumber' | i18n" @@ -168,7 +168,7 @@ type="button" bitIconButton="bwi-clone" bitSuffix - [appA11yTitle]="'copyEmail' | i18n" + [label]="'copyEmail' | i18n" [appCopyClick]="cipher.identity.email" showToast [valueLabel]="'email' | i18n" @@ -182,7 +182,7 @@ type="button" bitIconButton="bwi-clone" bitSuffix - [appA11yTitle]="'copyPhone' | i18n" + [label]="'copyPhone' | i18n" [appCopyClick]="cipher.identity.phone" showToast [valueLabel]="'phone' | i18n" @@ -204,7 +204,7 @@ type="button" bitIconButton="bwi-clone" bitSuffix - [appA11yTitle]="'copyAddress' | i18n" + [label]="'copyAddress' | i18n" [appCopyClick]="addressFields" showToast [valueLabel]="'address' | i18n" diff --git a/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.html b/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.html index cefd6305973..1f385fffc93 100644 --- a/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.html +++ b/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.html @@ -33,7 +33,7 @@ buttonType="danger" class="tw-ml-auto" bitIconButton="bwi-trash" - [appA11yTitle]="'deleteFolder' | i18n" + [label]="'deleteFolder' | i18n" [bitAction]="deleteFolder" > diff --git a/libs/vault/src/components/download-attachment/download-attachment.component.html b/libs/vault/src/components/download-attachment/download-attachment.component.html index e6a20ba044b..47ee3c4761c 100644 --- a/libs/vault/src/components/download-attachment/download-attachment.component.html +++ b/libs/vault/src/components/download-attachment/download-attachment.component.html @@ -4,5 +4,5 @@ buttonType="main" size="small" type="button" - [appA11yTitle]="'downloadAttachmentName' | i18n: attachment.fileName" + [label]="'downloadAttachmentName' | i18n: attachment.fileName" > diff --git a/libs/vault/src/components/password-history-view/password-history-view.component.html b/libs/vault/src/components/password-history-view/password-history-view.component.html index 7510d80a549..05772fde461 100644 --- a/libs/vault/src/components/password-history-view/password-history-view.component.html +++ b/libs/vault/src/components/password-history-view/password-history-view.component.html @@ -13,7 +13,7 @@