diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8affac3387b..676c4b4657b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -174,6 +174,7 @@ apps/desktop/src/key-management @bitwarden/team-key-management-dev apps/web/src/app/key-management @bitwarden/team-key-management-dev apps/browser/src/key-management @bitwarden/team-key-management-dev apps/cli/src/key-management @bitwarden/team-key-management-dev +bitwarden_license/bit-web/src/app/key-management @bitwarden/team-key-management-dev libs/key-management @bitwarden/team-key-management-dev libs/key-management-ui @bitwarden/team-key-management-dev libs/common/src/key-management @bitwarden/team-key-management-dev diff --git a/.github/workflows/respond.yml b/.github/workflows/respond.yml new file mode 100644 index 00000000000..d940ceee756 --- /dev/null +++ b/.github/workflows/respond.yml @@ -0,0 +1,28 @@ +name: Respond + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +permissions: {} + +jobs: + respond: + name: Respond + uses: bitwarden/gh-actions/.github/workflows/_respond.yml@main + secrets: + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + permissions: + actions: read + contents: write + id-token: write + issues: write + pull-requests: write diff --git a/.github/workflows/test-browser-interactions.yml b/.github/workflows/test-browser-interactions.yml index a5b92563f5a..fb31a93d51f 100644 --- a/.github/workflows/test-browser-interactions.yml +++ b/.github/workflows/test-browser-interactions.yml @@ -73,7 +73,7 @@ jobs: - name: Trigger test-all workflow in browser-interactions-testing if: steps.changed-files.outputs.monitored == 'true' - uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3.0.0 + uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4.0.0 with: token: ${{ steps.app-token.outputs.token }} repository: "bitwarden/browser-interactions-testing" diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 29601bfa70c..4f230dd9883 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -588,6 +588,9 @@ "view": { "message": "View" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1031,18 @@ "editedItem": { "message": "Item saved" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Do you really want to send to the trash?" }, @@ -1676,9 +1691,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -3240,6 +3276,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4050,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, diff --git a/apps/browser/src/autofill/notification/bar.html b/apps/browser/src/autofill/notification/bar.html index 90df670d29c..c0b57de612e 100644 --- a/apps/browser/src/autofill/notification/bar.html +++ b/apps/browser/src/autofill/notification/bar.html @@ -5,55 +5,5 @@ Bitwarden - - -
-
- - - -
-
-
- -
-
- - - - - - - + diff --git a/apps/browser/src/autofill/notification/bar.scss b/apps/browser/src/autofill/notification/bar.scss deleted file mode 100644 index c91c5f3ebac..00000000000 --- a/apps/browser/src/autofill/notification/bar.scss +++ /dev/null @@ -1,304 +0,0 @@ -@import "../shared/styles/variables"; - -body { - margin: 0; - padding: 0; - height: 100%; - font-size: 14px; - line-height: 16px; - font-family: $font-family-sans-serif; - - @include themify($themes) { - color: themed("textColor"); - background-color: themed("backgroundColor"); - } -} - -img { - margin: 0; - padding: 0; - border: 0; -} - -button, -select { - font-size: $font-size-base; - font-family: $font-family-sans-serif; -} - -.outer-wrapper { - display: block; - position: relative; - padding: 8px; - min-height: 42px; - border: 1px solid transparent; - border-bottom: 2px solid transparent; - border-radius: 4px; - box-sizing: border-box; - - @include themify($themes) { - border-color: themed("borderColor"); - border-bottom-color: themed("primaryColor"); - } - - &.success-event { - @include themify($themes) { - border-bottom-color: themed("successColor"); - } - } - - &.error-event { - @include themify($themes) { - border-bottom-color: themed("errorColor"); - } - } -} - -.inner-wrapper { - display: grid; - grid-template-columns: auto max-content; -} - -.outer-wrapper > *, -.inner-wrapper > * { - align-self: center; -} - -#logo { - width: 24px; - height: 24px; - display: block; -} - -.logo-wrapper { - position: absolute; - top: 8px; - left: 10px; - overflow: hidden; -} - -#close-button { - display: flex; - align-items: center; - justify-content: center; - width: 30px; - height: 30px; - margin-right: 10px; - padding: 0; - - &:hover { - @include themify($themes) { - border-color: rgba(themed("textColor"), 0.2); - background-color: rgba(themed("textColor"), 0.2); - } - } -} - -#close { - display: block; - width: 16px; - height: 16px; - - > path { - @include themify($themes) { - fill: themed("textColor"); - } - } -} - -.notification-close { - position: absolute; - top: 6px; - right: 6px; -} - -#content .inner-wrapper { - display: flex; - flex-wrap: wrap; - align-items: flex-start; - - .notification-body { - width: 100%; - padding: 4px 38px 24px 42px; - font-weight: 400; - } - - .notification-actions { - display: flex; - width: 100%; - align-items: stretch; - justify-content: flex-end; - - #never-save { - margin-right: auto; - padding: 0; - font-size: 16px; - font-weight: 500; - letter-spacing: 0.5px; - } - - #select-folder { - width: 125px; - margin-right: 6px; - font-size: 12px; - appearance: none; - background-repeat: no-repeat; - background-position: center right 4px; - background-size: 16px; - - @include themify($themes) { - color: themed("mutedTextColor"); - border-color: themed("mutedTextColor"); - } - - &:not([disabled]) { - display: block; - } - } - - .primary, - .secondary { - font-size: 12px; - } - - .secondary { - margin-right: 6px; - border-width: 1px; - } - - .primary { - margin-right: 2px; - } - - &.success-message, - &.error-message { - padding: 4px 36px 6px 42px; - } - } -} - -button { - padding: 4px 8px; - border-radius: $border-radius; - border: 1px solid transparent; - cursor: pointer; -} - -button.primary:not(.neutral) { - @include themify($themes) { - background-color: themed("primaryColor"); - color: themed("textContrast"); - border-color: themed("primaryColor"); - } - - &:hover { - @include themify($themes) { - background-color: darken(themed("primaryColor"), 1.5%); - color: darken(themed("textContrast"), 6%); - } - } -} - -button.secondary:not(.neutral) { - @include themify($themes) { - background-color: themed("backgroundColor"); - color: themed("mutedTextColor"); - border-color: themed("mutedTextColor"); - } - - &:hover { - @include themify($themes) { - background-color: themed("backgroundOffsetColor"); - color: darken(themed("mutedTextColor"), 6%); - } - } -} - -button.link, -button.neutral { - @include themify($themes) { - background-color: transparent; - color: themed("primaryColor"); - } - - &:hover { - text-decoration: underline; - - @include themify($themes) { - color: darken(themed("primaryColor"), 6%); - } - } -} - -select { - padding: 4px 6px; - border: 1px solid #000000; - border-radius: $border-radius; - - @include themify($themes) { - color: themed("textColor"); - background-color: themed("inputBackgroundColor"); - border-color: themed("inputBorderColor"); - } -} - -.success-message { - display: flex; - align-items: center; - justify-content: center; - - @include themify($themes) { - color: themed("successColor"); - } - - svg { - margin-right: 8px; - - path { - @include themify($themes) { - fill: themed("successColor"); - } - } - } -} - -.error-message { - @include themify($themes) { - color: themed("errorColor"); - } -} - -.success-event, -.error-event { - .notification-body { - display: none; - } -} - -@media screen and (max-width: 768px) { - #select-folder { - display: none; - } -} - -@media print { - body { - display: none; - } -} - -.theme_light { - #content .inner-wrapper { - #select-folder { - background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHhtbG5zOnhsaW5rPSdodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rJyB3aWR0aD0nMTYnIGhlaWdodD0nMTYnIGZpbGw9J25vbmUnPjxwYXRoIHN0cm9rZT0nIzIxMjUyOScgZD0nbTUgNiAzIDMgMy0zJy8+PC9zdmc+"); - } - } -} - -.theme_dark { - #content .inner-wrapper { - #select-folder { - background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxNicgaGVpZ2h0PScxNicgZmlsbD0nbm9uZSc+PHBhdGggc3Ryb2tlPScjZmZmZmZmJyBkPSdtNSA2IDMgMyAzLTMnLz48L3N2Zz4="); - } - } -} diff --git a/apps/browser/src/autofill/notification/bar.ts b/apps/browser/src/autofill/notification/bar.ts index fcf91ca2e91..3673a9f7321 100644 --- a/apps/browser/src/autofill/notification/bar.ts +++ b/apps/browser/src/autofill/notification/bar.ts @@ -187,8 +187,6 @@ async function initNotificationBar(message: NotificationBarWindowMessage) { const notificationTestId = getNotificationTestId(notificationType); appendHeaderMessageToTitle(headerMessage); - document.body.innerHTML = ""; - if (isVaultLocked) { const notificationConfig = { ...notificationBarIframeInitData, diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/button/bootstrap-autofill-inline-menu-button.ts b/apps/browser/src/autofill/overlay/inline-menu/pages/button/bootstrap-autofill-inline-menu-button.ts index 36ef3897c56..dffacce0ffc 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/button/bootstrap-autofill-inline-menu-button.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/button/bootstrap-autofill-inline-menu-button.ts @@ -1,10 +1,7 @@ import { AutofillOverlayElement } from "../../../../enums/autofill-overlay.enum"; import { AutofillInlineMenuButton } from "./autofill-inline-menu-button"; - -// FIXME: Remove when updating file. Eslint update -// eslint-disable-next-line @typescript-eslint/no-require-imports -require("./button.scss"); +import "./button.css"; (function () { globalThis.customElements.define(AutofillOverlayElement.Button, AutofillInlineMenuButton); diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/button/button.scss b/apps/browser/src/autofill/overlay/inline-menu/pages/button/button.css similarity index 74% rename from apps/browser/src/autofill/overlay/inline-menu/pages/button/button.scss rename to apps/browser/src/autofill/overlay/inline-menu/pages/button/button.css index 64e54179893..a1fce6f14da 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/button/button.scss +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/button/button.css @@ -1,5 +1,3 @@ -@import "../../../../shared/styles/variables"; - * { box-sizing: border-box; } @@ -27,10 +25,10 @@ autofill-inline-menu-button { border: none; background: transparent; cursor: pointer; - - .inline-menu-button-svg-icon { - display: block; - width: 100%; - height: auto; - } +} + +.inline-menu-button .inline-menu-button-svg-icon { + display: block; + width: 100%; + height: auto; } diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index 373c4d64b52..1a3c846dcb2 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -62,6 +62,8 @@ import { initPopupClosedListener } from "../platform/services/popup-view-cache-b import { routerTransition } from "./app-routing.animations"; import { DesktopSyncVerificationDialogComponent } from "./components/desktop-sync-verification-dialog.component"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-root", styles: [], diff --git a/apps/browser/src/popup/components/desktop-sync-verification-dialog.component.ts b/apps/browser/src/popup/components/desktop-sync-verification-dialog.component.ts index 2ca24da6c75..510348927ce 100644 --- a/apps/browser/src/popup/components/desktop-sync-verification-dialog.component.ts +++ b/apps/browser/src/popup/components/desktop-sync-verification-dialog.component.ts @@ -15,6 +15,8 @@ export type DesktopSyncVerificationDialogParams = { fingerprint: string[]; }; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "desktop-sync-verification-dialog.component.html", imports: [JslibModule, ButtonModule, DialogModule], diff --git a/apps/browser/src/popup/tabs-v2.component.ts b/apps/browser/src/popup/tabs-v2.component.ts index f1e42799b35..1c409fee639 100644 --- a/apps/browser/src/popup/tabs-v2.component.ts +++ b/apps/browser/src/popup/tabs-v2.component.ts @@ -17,6 +17,8 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { NavButton } from "../platform/popup/layout/popup-tab-navigation.component"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-tabs-v2", templateUrl: "./tabs-v2.component.html", diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html new file mode 100644 index 00000000000..77801edc8fe --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html @@ -0,0 +1,68 @@ + + {{ "confirmAutofill" | i18n }} +
+

+ {{ "confirmAutofillDesc" | i18n }} +

+ @if (savedUrls.length === 1) { +

+ {{ "savedWebsite" | i18n }} +

+ +
+ {{ savedUrls[0] }} +
+
+ } + @if (savedUrls.length > 1) { +
+

+ {{ "savedWebsites" | i18n: savedUrls.length }} +

+ +
+
+
+ +
+ {{ url }} +
+
+
+
+ } +

+ {{ "currentWebsite" | i18n }} +

+ +
+ {{ currentUrl }} +
+
+
+ + + +
+
+
diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts new file mode 100644 index 00000000000..1fe3dfaf25a --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts @@ -0,0 +1,192 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { provideNoopAnimations } from "@angular/platform-browser/animations"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { DIALOG_DATA, DialogRef, DialogService } from "@bitwarden/components"; + +import { + AutofillConfirmationDialogComponent, + AutofillConfirmationDialogResult, + AutofillConfirmationDialogParams, +} from "./autofill-confirmation-dialog.component"; + +describe("AutofillConfirmationDialogComponent", () => { + let fixture: ComponentFixture; + let component: AutofillConfirmationDialogComponent; + + const dialogRef = { + close: jest.fn(), + } as unknown as DialogRef; + + const params: AutofillConfirmationDialogParams = { + currentUrl: "https://example.com/path?q=1", + savedUrls: ["https://one.example.com/a", "https://two.example.com/b", "not-a-url.example"], + }; + + beforeEach(async () => { + jest.spyOn(Utils, "getHostname").mockImplementation((value: string | null | undefined) => { + if (typeof value !== "string" || !value) { + return ""; + } + try { + // handle non-URL host strings gracefully + if (!value.includes("://")) { + return value; + } + return new URL(value).hostname; + } catch { + return ""; + } + }); + + await TestBed.configureTestingModule({ + imports: [AutofillConfirmationDialogComponent], + providers: [ + provideNoopAnimations(), + { provide: DIALOG_DATA, useValue: params }, + { provide: DialogRef, useValue: dialogRef }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: DialogService, useValue: {} }, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(AutofillConfirmationDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("normalizes currentUrl and savedUrls via Utils.getHostname", () => { + expect(Utils.getHostname).toHaveBeenCalledTimes(1 + (params.savedUrls?.length ?? 0)); + // current + expect(component.currentUrl).toBe("example.com"); + // saved + expect(component.savedUrls).toEqual([ + "one.example.com", + "two.example.com", + "not-a-url.example", + ]); + }); + + it("renders normalized values into the template (shallow check)", () => { + const text = fixture.nativeElement.textContent as string; + expect(text).toContain("example.com"); + expect(text).toContain("one.example.com"); + expect(text).toContain("two.example.com"); + expect(text).toContain("not-a-url.example"); + }); + + it("emits Canceled on close()", () => { + const spy = jest.spyOn(dialogRef, "close"); + component["close"](); + expect(spy).toHaveBeenCalledWith(AutofillConfirmationDialogResult.Canceled); + }); + + it("emits AutofillAndUrlAdded on autofillAndAddUrl()", () => { + const spy = jest.spyOn(dialogRef, "close"); + component["autofillAndAddUrl"](); + expect(spy).toHaveBeenCalledWith(AutofillConfirmationDialogResult.AutofillAndUrlAdded); + }); + + it("emits AutofilledOnly on autofillOnly()", () => { + const spy = jest.spyOn(dialogRef, "close"); + component["autofillOnly"](); + expect(spy).toHaveBeenCalledWith(AutofillConfirmationDialogResult.AutofilledOnly); + }); + + it("applies collapsed list gradient class by default, then clears it after viewAllSavedUrls()", () => { + const initial = component["savedUrlsListClass"]; + expect(initial).toContain("gradient"); + + component["viewAllSavedUrls"](); + fixture.detectChanges(); + + const expanded = component["savedUrlsListClass"]; + expect(expanded).toBe(""); + }); + + it("handles empty savedUrls gracefully", async () => { + const newParams: AutofillConfirmationDialogParams = { + currentUrl: "https://bitwarden.com/help", + savedUrls: [], + }; + + const newFixture = TestBed.createComponent(AutofillConfirmationDialogComponent); + const newInstance = newFixture.componentInstance; + + (newInstance as any).params = newParams; + const fresh = new AutofillConfirmationDialogComponent( + newParams as any, + dialogRef, + ) as AutofillConfirmationDialogComponent; + + expect(fresh.savedUrls).toEqual([]); + expect(fresh.currentUrl).toBe("bitwarden.com"); + }); + + it("handles undefined savedUrls by defaulting to [] and empty strings from Utils.getHostname", () => { + const localParams: AutofillConfirmationDialogParams = { + currentUrl: "https://sub.domain.tld/x", + }; + + const local = new AutofillConfirmationDialogComponent(localParams as any, dialogRef); + + expect(local.savedUrls).toEqual([]); + expect(local.currentUrl).toBe("sub.domain.tld"); + }); + + it("filters out falsy/invalid values from Utils.getHostname in savedUrls", () => { + (Utils.getHostname as jest.Mock).mockImplementationOnce(() => "example.com"); + (Utils.getHostname as jest.Mock) + .mockImplementationOnce(() => "ok.example") + .mockImplementationOnce(() => "") + .mockImplementationOnce(() => undefined as unknown as string); + + const edgeParams: AutofillConfirmationDialogParams = { + currentUrl: "https://example.com", + savedUrls: ["https://ok.example", "://bad", "%%%"], + }; + + const edge = new AutofillConfirmationDialogComponent(edgeParams as any, dialogRef); + + expect(edge.currentUrl).toBe("example.com"); + expect(edge.savedUrls).toEqual(["ok.example"]); + }); + + it("renders one current-url callout and N saved-url callouts", () => { + const callouts = Array.from( + fixture.nativeElement.querySelectorAll("bit-callout"), + ) as HTMLElement[]; + expect(callouts.length).toBe(1 + params.savedUrls!.length); + }); + + it("renders normalized hostnames into the DOM text", () => { + const text = (fixture.nativeElement.textContent as string).replace(/\s+/g, " "); + expect(text).toContain("example.com"); + expect(text).toContain("one.example.com"); + expect(text).toContain("two.example.com"); + }); + + it("shows the 'view all' button when savedUrls > 1 and hides it after click", () => { + const findViewAll = () => + fixture.nativeElement.querySelector( + "button.tw-text-sm.tw-font-bold.tw-cursor-pointer", + ) as HTMLButtonElement | null; + + let btn = findViewAll(); + expect(btn).toBeTruthy(); + + btn!.click(); + fixture.detectChanges(); + + btn = findViewAll(); + expect(btn).toBeFalsy(); + expect(component.savedUrlsExpanded).toBe(true); + }); +}); diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.ts b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.ts new file mode 100644 index 00000000000..71c07ad8bfc --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.ts @@ -0,0 +1,101 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, Inject } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { + DIALOG_DATA, + DialogConfig, + DialogRef, + ButtonModule, + DialogService, + DialogModule, + TypographyModule, + CalloutComponent, + LinkModule, +} from "@bitwarden/components"; + +export interface AutofillConfirmationDialogParams { + savedUrls?: string[]; + currentUrl: string; +} + +export const AutofillConfirmationDialogResult = Object.freeze({ + AutofillAndUrlAdded: "added", + AutofilledOnly: "autofilled", + Canceled: "canceled", +} as const); + +export type AutofillConfirmationDialogResultType = UnionOfValues< + typeof AutofillConfirmationDialogResult +>; + +@Component({ + templateUrl: "./autofill-confirmation-dialog.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + ButtonModule, + CalloutComponent, + CommonModule, + DialogModule, + LinkModule, + TypographyModule, + JslibModule, + ], +}) +export class AutofillConfirmationDialogComponent { + AutofillConfirmationDialogResult = AutofillConfirmationDialogResult; + + currentUrl: string = ""; + savedUrls: string[] = []; + savedUrlsExpanded = false; + + constructor( + @Inject(DIALOG_DATA) protected params: AutofillConfirmationDialogParams, + private dialogRef: DialogRef, + ) { + this.currentUrl = Utils.getHostname(params.currentUrl); + this.savedUrls = + params.savedUrls?.map((url) => Utils.getHostname(url) ?? "").filter(Boolean) ?? []; + } + + protected get savedUrlsListClass(): string { + return this.savedUrlsExpanded + ? "" + : `tw-relative + tw-max-h-24 + tw-overflow-hidden + after:tw-pointer-events-none after:tw-content-[''] + after:tw-absolute after:tw-inset-x-0 after:tw-bottom-0 + after:tw-h-8 after:tw-bg-gradient-to-t + after:tw-from-background after:tw-to-transparent + `; + } + + protected viewAllSavedUrls() { + this.savedUrlsExpanded = true; + } + + protected close() { + this.dialogRef.close(AutofillConfirmationDialogResult.Canceled); + } + + protected autofillAndAddUrl() { + this.dialogRef.close(AutofillConfirmationDialogResult.AutofillAndUrlAdded); + } + + protected autofillOnly() { + this.dialogRef.close(AutofillConfirmationDialogResult.AutofilledOnly); + } + + static open( + dialogService: DialogService, + config: DialogConfig, + ) { + return dialogService.open( + AutofillConfirmationDialogComponent, + { ...config }, + ); + } +} 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 3a48f7eb449..b05d19498ac 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 @@ -13,9 +13,17 @@ - + + @if (!(showAutofillConfirmation$ | async)) { + + } diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts new file mode 100644 index 00000000000..15a9ba8f8e3 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts @@ -0,0 +1,241 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { Router } from "@angular/router"; +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +import { CollectionService } from "@bitwarden/admin-console/common"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { + UriMatchStrategy, + UriMatchStrategySetting, +} from "@bitwarden/common/models/domain/domain-service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; +import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; +import { + AutofillConfirmationDialogComponent, + AutofillConfirmationDialogResult, +} from "../autofill-confirmation-dialog/autofill-confirmation-dialog.component"; + +import { ItemMoreOptionsComponent } from "./item-more-options.component"; + +describe("ItemMoreOptionsComponent", () => { + let fixture: ComponentFixture; + let component: ItemMoreOptionsComponent; + + const dialogService = { + openSimpleDialog: jest.fn().mockResolvedValue(true), + open: jest.fn(), + }; + const featureFlag$ = new BehaviorSubject(false); + const configService = { + getFeatureFlag$: jest.fn().mockImplementation(() => featureFlag$.asObservable()), + }; + const cipherService = { + getFullCipherView: jest.fn(), + encrypt: jest.fn(), + updateWithServer: jest.fn(), + softDeleteWithServer: jest.fn(), + }; + const autofillSvc = { + doAutofill: jest.fn(), + doAutofillAndSave: jest.fn(), + currentAutofillTab$: new BehaviorSubject<{ url?: string | null } | null>(null), + autofillAllowed$: new BehaviorSubject(true), + }; + + const uriMatchStrategy$ = new BehaviorSubject(UriMatchStrategy.Domain); + + const domainSettingsService = { + resolvedDefaultUriMatchStrategy$: uriMatchStrategy$.asObservable(), + }; + + const hasSearchText$ = new BehaviorSubject(false); + const vaultPopupItemsService = { + hasSearchText$: hasSearchText$.asObservable(), + }; + + const baseCipher = { + id: "cipher-1", + login: { + uris: [ + { uri: "https://one.example.com" }, + { uri: "" }, + { uri: undefined as unknown as string }, + { uri: "https://two.example.com/a" }, + ], + username: "user", + }, + favorite: false, + reprompt: 0, + type: CipherType.Login, + viewPassword: true, + edit: true, + } as any; + + beforeEach(waitForAsync(async () => { + jest.clearAllMocks(); + + cipherService.getFullCipherView.mockImplementation(async (c) => ({ ...baseCipher, ...c })); + + TestBed.configureTestingModule({ + imports: [ItemMoreOptionsComponent, NoopAnimationsModule], + providers: [ + { provide: ConfigService, useValue: configService }, + { provide: CipherService, useValue: cipherService }, + { provide: VaultPopupAutofillService, useValue: autofillSvc }, + + { provide: I18nService, useValue: { t: (k: string) => k } }, + { provide: AccountService, useValue: { activeAccount$: of({ id: "UserId" }) } }, + { provide: OrganizationService, useValue: { hasOrganizations: () => of(false) } }, + { + provide: CipherAuthorizationService, + useValue: { canDeleteCipher$: () => of(true), canCloneCipher$: () => of(true) }, + }, + { provide: CollectionService, useValue: { decryptedCollections$: () => of([]) } }, + { provide: RestrictedItemTypesService, useValue: { restricted$: of([]) } }, + { provide: CipherArchiveService, useValue: { userCanArchive$: () => of(true) } }, + { provide: ToastService, useValue: { showToast: () => {} } }, + { provide: Router, useValue: { navigate: () => Promise.resolve(true) } }, + { provide: PasswordRepromptService, useValue: mock() }, + { + provide: DomainSettingsService, + useValue: domainSettingsService, + }, + { + provide: VaultPopupItemsService, + useValue: vaultPopupItemsService, + }, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }); + TestBed.overrideProvider(DialogService, { useValue: dialogService }); + await TestBed.compileComponents(); + fixture = TestBed.createComponent(ItemMoreOptionsComponent); + component = fixture.componentInstance; + component.cipher = baseCipher; + })); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function mockConfirmDialogResult(result: string) { + const openSpy = jest + .spyOn(AutofillConfirmationDialogComponent, "open") + .mockReturnValue({ closed: of(result) } as any); + return openSpy; + } + + it("calls doAutofill without showing the confirmation dialog when the feature flag is disabled or search text is not present", async () => { + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + + await component.doAutofill(); + + expect(cipherService.getFullCipherView).toHaveBeenCalled(); + expect(autofillSvc.doAutofill).toHaveBeenCalledTimes(1); + expect(autofillSvc.doAutofill).toHaveBeenCalledWith( + expect.objectContaining({ id: "cipher-1" }), + false, + ); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + }); + + it("opens the confirmation dialog with filtered saved URLs when the feature flag is enabled and search text is present", async () => { + featureFlag$.next(true); + hasSearchText$.next(true); + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" }); + const openSpy = mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); + + await component.doAutofill(); + + expect(openSpy).toHaveBeenCalledTimes(1); + const args = openSpy.mock.calls[0][1]; + expect(args.data.currentUrl).toBe("https://page.example.com/path"); + expect(args.data.savedUrls).toEqual(["https://one.example.com", "https://two.example.com/a"]); + }); + + it("does nothing when the user cancels the autofill confirmation dialog", async () => { + featureFlag$.next(true); + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); + + await component.doAutofill(); + + expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + }); + + it("autofills the item without adding the URL when the user selects 'AutofilledOnly'", async () => { + featureFlag$.next(true); + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly); + + await component.doAutofill(); + + expect(autofillSvc.doAutofill).toHaveBeenCalledTimes(1); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + }); + + it("autofills the item and adds the URL when the user selects 'AutofillAndUrlAdded'", async () => { + featureFlag$.next(true); + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofillAndUrlAdded); + + await component.doAutofill(); + + expect(autofillSvc.doAutofillAndSave).toHaveBeenCalledTimes(1); + expect(autofillSvc.doAutofillAndSave.mock.calls[0][1]).toBe(false); + expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); + }); + + it("only shows the exact match dialog when the uri match strategy is Exact and no URIs match", async () => { + featureFlag$.next(true); + uriMatchStrategy$.next(UriMatchStrategy.Exact); + hasSearchText$.next(true); + autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" }); + + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).toHaveBeenCalledTimes(1); + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith( + expect.objectContaining({ + title: expect.objectContaining({ key: "cannotAutofill" }), + content: expect.objectContaining({ key: "cannotAutofillExactMatch" }), + type: "info", + }), + ); + expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + }); + + it("hides the 'Fill and Save' button when showAutofillConfirmation$ is true", async () => { + // Enable both feature flag and search text → makes showAutofillConfirmation$ true + featureFlag$.next(true); + hasSearchText$.next(true); + + fixture.detectChanges(); + await fixture.whenStable(); + + const fillAndSaveButton = fixture.nativeElement.querySelector( + "button[bitMenuItem]:not([disabled])", + ); + + const buttonText = fillAndSaveButton?.textContent?.trim().toLowerCase() ?? ""; + expect(buttonText.includes("fillAndSave".toLowerCase())).toBe(false); + }); +}); diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts index 94016d2670f..40b6476053b 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -1,5 +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 { booleanAttribute, Component, Input } from "@angular/core"; import { Router, RouterModule } from "@angular/router"; @@ -11,8 +9,12 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { CipherId } from "@bitwarden/common/types/guid"; +import { CipherId, UserId } from "@bitwarden/common/types/guid"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; @@ -32,7 +34,12 @@ import { import { PasswordRepromptService } from "@bitwarden/vault"; import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; +import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; import { AddEditQueryParams } from "../add-edit/add-edit-v2.component"; +import { + AutofillConfirmationDialogComponent, + AutofillConfirmationDialogResult, +} from "../autofill-confirmation-dialog/autofill-confirmation-dialog.component"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -42,7 +49,7 @@ import { AddEditQueryParams } from "../add-edit/add-edit-v2.component"; imports: [ItemModule, IconButtonModule, MenuModule, CommonModule, JslibModule, RouterModule], }) export class ItemMoreOptionsComponent { - private _cipher$ = new BehaviorSubject(undefined); + private _cipher$ = new BehaviorSubject({} as CipherViewLike); // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @@ -64,7 +71,7 @@ export class ItemMoreOptionsComponent { // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ transform: booleanAttribute }) - showViewOption: boolean; + showViewOption = false; /** * Flag to hide the autofill menu options. Used for items that are @@ -73,10 +80,17 @@ export class ItemMoreOptionsComponent { // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ transform: booleanAttribute }) - hideAutofillOptions: boolean; + hideAutofillOptions = false; protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$; + protected showAutofillConfirmation$ = combineLatest([ + this.configService.getFeatureFlag$(FeatureFlag.AutofillConfirmation), + this.vaultPopupItemsService.hasSearchText$, + ]).pipe(map(([isFeatureFlagEnabled, hasSearchText]) => isFeatureFlagEnabled && hasSearchText)); + + protected uriMatchStrategy$ = this.domainSettingsService.resolvedDefaultUriMatchStrategy$; + /** * Observable that emits a boolean value indicating if the user is authorized to clone the cipher. * @protected @@ -146,6 +160,9 @@ export class ItemMoreOptionsComponent { private collectionService: CollectionService, private restrictedItemTypesService: RestrictedItemTypesService, private cipherArchiveService: CipherArchiveService, + private configService: ConfigService, + private vaultPopupItemsService: VaultPopupItemsService, + private domainSettingsService: DomainSettingsService, ) {} get canEdit() { @@ -177,14 +194,63 @@ export class ItemMoreOptionsComponent { return this.cipher.favorite ? "unfavorite" : "favorite"; } - async doAutofill() { - const cipher = await this.cipherService.getFullCipherView(this.cipher); - await this.vaultPopupAutofillService.doAutofill(cipher); - } - async doAutofillAndSave() { const cipher = await this.cipherService.getFullCipherView(this.cipher); - await this.vaultPopupAutofillService.doAutofillAndSave(cipher, false); + await this.vaultPopupAutofillService.doAutofillAndSave(cipher); + } + + async doAutofill() { + const cipher = await this.cipherService.getFullCipherView(this.cipher); + + const showAutofillConfirmation = await firstValueFrom(this.showAutofillConfirmation$); + + if (!showAutofillConfirmation) { + await this.vaultPopupAutofillService.doAutofill(cipher, false); + return; + } + + const uriMatchStrategy = await firstValueFrom(this.uriMatchStrategy$); + if (uriMatchStrategy === UriMatchStrategy.Exact) { + await this.dialogService.openSimpleDialog({ + title: { key: "cannotAutofill" }, + content: { key: "cannotAutofillExactMatch" }, + type: "info", + acceptButtonText: { key: "okay" }, + cancelButtonText: null, + }); + return; + } + + const currentTab = await firstValueFrom(this.vaultPopupAutofillService.currentAutofillTab$); + + if (!currentTab?.url) { + await this.dialogService.openSimpleDialog({ + title: { key: "error" }, + content: { key: "errorGettingAutoFillData" }, + type: "danger", + }); + return; + } + + const ref = AutofillConfirmationDialogComponent.open(this.dialogService, { + data: { + currentUrl: currentTab?.url || "", + savedUrls: cipher.login?.uris?.filter((u) => u.uri).map((u) => u.uri!) ?? [], + }, + }); + + const result = await firstValueFrom(ref.closed); + + switch (result) { + case AutofillConfirmationDialogResult.Canceled: + return; + case AutofillConfirmationDialogResult.AutofilledOnly: + await this.vaultPopupAutofillService.doAutofill(cipher); + return; + case AutofillConfirmationDialogResult.AutofillAndUrlAdded: + await this.vaultPopupAutofillService.doAutofillAndSave(cipher, false); + return; + } } async onView() { @@ -204,15 +270,14 @@ export class ItemMoreOptionsComponent { const cipher = await this.cipherService.getFullCipherView(this.cipher); cipher.favorite = !cipher.favorite; - const activeUserId = await firstValueFrom( + const activeUserId = (await firstValueFrom( this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); + )) as UserId; const encryptedCipher = await this.cipherService.encrypt(cipher, activeUserId); await this.cipherService.updateWithServer(encryptedCipher); this.toastService.showToast({ variant: "success", - title: null, message: this.i18nService.t( this.cipher.favorite ? "itemAddedToFavorites" : "itemRemovedFromFavorites", ), diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts index a1820a975f1..afe9d61d5af 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts @@ -261,6 +261,13 @@ export class VaultPopupItemsService { this.remainingCiphers$.pipe(map(() => false)), ).pipe(startWith(true), distinctUntilChanged(), shareReplay({ refCount: false, bufferSize: 1 })); + /** Observable that indicates whether there is search text present. + */ + hasSearchText$: Observable = this._hasSearchText.pipe( + distinctUntilChanged(), + shareReplay({ bufferSize: 1, refCount: true }), + ); + /** * Observable that indicates whether a filter or search text is currently applied to the ciphers. */ diff --git a/apps/desktop/desktop_native/core/src/biometric_v2/linux.rs b/apps/desktop/desktop_native/core/src/biometric_v2/linux.rs new file mode 100644 index 00000000000..44cba4a9e5b --- /dev/null +++ b/apps/desktop/desktop_native/core/src/biometric_v2/linux.rs @@ -0,0 +1,141 @@ +//! This file implements Polkit based system unlock. +//! +//! # Security +//! This section describes the assumed security model and security guarantees achieved. In the required security +//! guarantee is that a locked vault - a running app - cannot be unlocked when the device (user-space) +//! is compromised in this state. +//! +//! When first unlocking the app, the app sends the user-key to this module, which holds it in secure memory, +//! protected by memfd_secret. This makes it inaccessible to other processes, even if they compromise root, a kernel compromise +//! has circumventable best-effort protections. While the app is running this key is held in memory, even if locked. +//! When unlocking, the app will prompt the user via `polkit` to get a yes/no decision on whether to release the key to the app. + +use anyhow::{anyhow, Result}; +use std::sync::Arc; +use tokio::sync::Mutex; +use tracing::{debug, warn}; +use zbus::Connection; +use zbus_polkit::policykit1::{AuthorityProxy, CheckAuthorizationFlags, Subject}; + +use crate::secure_memory::*; + +pub struct BiometricLockSystem { + // The userkeys that are held in memory MUST be protected from memory dumping attacks, to ensure + // locked vaults cannot be unlocked + secure_memory: Arc>, +} + +impl BiometricLockSystem { + pub fn new() -> Self { + Self { + secure_memory: Arc::new(Mutex::new( + crate::secure_memory::encrypted_memory_store::EncryptedMemoryStore::new(), + )), + } + } +} + +impl Default for BiometricLockSystem { + fn default() -> Self { + Self::new() + } +} + +impl super::BiometricTrait for BiometricLockSystem { + async fn authenticate(&self, _hwnd: Vec, _message: String) -> Result { + polkit_authenticate_bitwarden_policy().await + } + + async fn authenticate_available(&self) -> Result { + polkit_is_bitwarden_policy_available().await + } + + async fn enroll_persistent(&self, _user_id: &str, _key: &[u8]) -> Result<()> { + // Not implemented + Ok(()) + } + + async fn provide_key(&self, user_id: &str, key: &[u8]) { + self.secure_memory + .lock() + .await + .put(user_id.to_string(), key); + } + + async fn unlock(&self, user_id: &str, _hwnd: Vec) -> Result> { + if !polkit_authenticate_bitwarden_policy().await? { + return Err(anyhow!("Authentication failed")); + } + + self.secure_memory + .lock() + .await + .get(user_id) + .ok_or(anyhow!("No key found")) + } + + async fn unlock_available(&self, user_id: &str) -> Result { + Ok(self.secure_memory.lock().await.has(user_id)) + } + + async fn has_persistent(&self, _user_id: &str) -> Result { + Ok(false) + } + + async fn unenroll(&self, user_id: &str) -> Result<(), anyhow::Error> { + self.secure_memory.lock().await.remove(user_id); + Ok(()) + } +} + +/// Perform a polkit authorization against the bitwarden unlock policy. Note: This relies on no custom +/// rules in the system skipping the authorization check, in which case this counts as UV / authentication. +async fn polkit_authenticate_bitwarden_policy() -> Result { + debug!("[Polkit] Authenticating / performing UV"); + + let connection = Connection::system().await?; + let proxy = AuthorityProxy::new(&connection).await?; + let subject = Subject::new_for_owner(std::process::id(), None, None)?; + let details = std::collections::HashMap::new(); + let authorization_result = proxy + .check_authorization( + &subject, + "com.bitwarden.Bitwarden.unlock", + &details, + CheckAuthorizationFlags::AllowUserInteraction.into(), + "", + ) + .await; + + match authorization_result { + Ok(result) => Ok(result.is_authorized), + Err(e) => { + warn!("[Polkit] Error performing authentication: {:?}", e); + Ok(false) + } + } +} + +async fn polkit_is_bitwarden_policy_available() -> Result { + let connection = Connection::system().await?; + let proxy = AuthorityProxy::new(&connection).await?; + let actions = proxy.enumerate_actions("en").await?; + for action in actions { + if action.action_id == "com.bitwarden.Bitwarden.unlock" { + return Ok(true); + } + } + Ok(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + #[ignore] + async fn test_polkit_authenticate() { + let result = polkit_authenticate_bitwarden_policy().await; + assert!(result.is_ok()); + } +} diff --git a/apps/desktop/desktop_native/core/src/biometric_v2/mod.rs b/apps/desktop/desktop_native/core/src/biometric_v2/mod.rs index e37a101e2ae..669267b7829 100644 --- a/apps/desktop/desktop_native/core/src/biometric_v2/mod.rs +++ b/apps/desktop/desktop_native/core/src/biometric_v2/mod.rs @@ -1,7 +1,7 @@ use anyhow::Result; #[allow(clippy::module_inception)] -#[cfg_attr(target_os = "linux", path = "unimplemented.rs")] +#[cfg_attr(target_os = "linux", path = "linux.rs")] #[cfg_attr(target_os = "macos", path = "unimplemented.rs")] #[cfg_attr(target_os = "windows", path = "windows.rs")] mod biometric_v2; diff --git a/apps/desktop/desktop_native/core/src/secure_memory/mod.rs b/apps/desktop/desktop_native/core/src/secure_memory/mod.rs index 8695904758e..d4323ce40dd 100644 --- a/apps/desktop/desktop_native/core/src/secure_memory/mod.rs +++ b/apps/desktop/desktop_native/core/src/secure_memory/mod.rs @@ -1,7 +1,7 @@ #[cfg(target_os = "windows")] pub(crate) mod dpapi; -mod encrypted_memory_store; +pub(crate) mod encrypted_memory_store; mod secure_key; /// The secure memory store provides an ephemeral key-value store for sensitive data. diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index 7666e9bef1b..abebdfa5fc3 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -67,6 +67,8 @@ import { DesktopSettingsService } from "../../platform/services/desktop-settings import { DesktopPremiumUpgradePromptService } from "../../services/desktop-premium-upgrade-prompt.service"; import { NativeMessagingManifestService } from "../services/native-messaging-manifest.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-settings", templateUrl: "settings.component.html", diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 60aa3bf7f6e..dea35bae516 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -94,6 +94,8 @@ const BroadcasterSubscriptionId = "AppComponent"; const IdleTimeout = 60000 * 10; // 10 minutes const SyncInterval = 6 * 60 * 60 * 1000; // 6 hours +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-root", styles: [], @@ -118,14 +120,26 @@ const SyncInterval = 6 * 60 * 60 * 1000; // 6 hours standalone: false, }) export class AppComponent implements OnInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("settings", { read: ViewContainerRef, static: true }) settingsRef: ViewContainerRef; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("premium", { read: ViewContainerRef, static: true }) premiumRef: ViewContainerRef; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("passwordHistory", { read: ViewContainerRef, static: true }) passwordHistoryRef: ViewContainerRef; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("exportVault", { read: ViewContainerRef, static: true }) exportVaultModalRef: ViewContainerRef; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("appGenerator", { read: ViewContainerRef, static: true }) generatorModalRef: ViewContainerRef; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("loginApproval", { read: ViewContainerRef, static: true }) loginApprovalModalRef: ViewContainerRef; diff --git a/apps/desktop/src/app/components/avatar.component.ts b/apps/desktop/src/app/components/avatar.component.ts index 1fba864686c..d17ebb5b942 100644 --- a/apps/desktop/src/app/components/avatar.component.ts +++ b/apps/desktop/src/app/components/avatar.component.ts @@ -5,20 +5,38 @@ import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-avatar", template: ``, standalone: false, }) export class AvatarComponent implements OnChanges, OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() size = 45; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() charCount = 2; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() fontSize = 20; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() dynamic = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() circle = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() color?: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() id?: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() text?: string; private svgCharCount = 2; diff --git a/apps/desktop/src/app/components/browser-sync-verification-dialog.component.ts b/apps/desktop/src/app/components/browser-sync-verification-dialog.component.ts index 713dc07e803..5d3c777f333 100644 --- a/apps/desktop/src/app/components/browser-sync-verification-dialog.component.ts +++ b/apps/desktop/src/app/components/browser-sync-verification-dialog.component.ts @@ -7,6 +7,8 @@ export type BrowserSyncVerificationDialogParams = { fingerprint: string[]; }; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "browser-sync-verification-dialog.component.html", imports: [JslibModule, ButtonModule, DialogModule], diff --git a/apps/desktop/src/app/components/user-verification.component.ts b/apps/desktop/src/app/components/user-verification.component.ts index 31d38b10183..e19916c3d6b 100644 --- a/apps/desktop/src/app/components/user-verification.component.ts +++ b/apps/desktop/src/app/components/user-verification.component.ts @@ -11,6 +11,8 @@ import { FormFieldModule } from "@bitwarden/components"; * @deprecated Jan 24, 2024: Use new libs/auth UserVerificationDialogComponent or UserVerificationFormInputComponent instead. * Each client specific component should eventually be converted over to use one of these new components. */ +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-user-verification", imports: [CommonModule, JslibModule, ReactiveFormsModule, FormFieldModule, FormsModule], diff --git a/apps/desktop/src/app/components/verify-native-messaging-dialog.component.ts b/apps/desktop/src/app/components/verify-native-messaging-dialog.component.ts index 72284d007b6..14c2b137d73 100644 --- a/apps/desktop/src/app/components/verify-native-messaging-dialog.component.ts +++ b/apps/desktop/src/app/components/verify-native-messaging-dialog.component.ts @@ -7,6 +7,8 @@ export type VerifyNativeMessagingDialogData = { applicationName: string; }; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "verify-native-messaging-dialog.component.html", imports: [JslibModule, ButtonModule, DialogModule], diff --git a/apps/desktop/src/app/layout/account-switcher.component.ts b/apps/desktop/src/app/layout/account-switcher.component.ts index a54674c3a1e..6a7e274ade4 100644 --- a/apps/desktop/src/app/layout/account-switcher.component.ts +++ b/apps/desktop/src/app/layout/account-switcher.component.ts @@ -31,6 +31,8 @@ type InactiveAccount = ActiveAccount & { authenticationStatus: AuthenticationStatus; }; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-account-switcher", templateUrl: "account-switcher.component.html", diff --git a/apps/desktop/src/app/layout/header.component.ts b/apps/desktop/src/app/layout/header.component.ts index 9aef093423f..9630e3b1914 100644 --- a/apps/desktop/src/app/layout/header.component.ts +++ b/apps/desktop/src/app/layout/header.component.ts @@ -1,5 +1,7 @@ import { Component } from "@angular/core"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-header", templateUrl: "header.component.html", diff --git a/apps/desktop/src/app/layout/nav.component.ts b/apps/desktop/src/app/layout/nav.component.ts index bcc2b57fb17..72064a4de51 100644 --- a/apps/desktop/src/app/layout/nav.component.ts +++ b/apps/desktop/src/app/layout/nav.component.ts @@ -4,6 +4,8 @@ import { RouterLink, RouterLinkActive } from "@angular/router"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-nav", templateUrl: "nav.component.html", diff --git a/apps/desktop/src/app/layout/search/search.component.ts b/apps/desktop/src/app/layout/search/search.component.ts index 70196d74dda..c0b088a13d9 100644 --- a/apps/desktop/src/app/layout/search/search.component.ts +++ b/apps/desktop/src/app/layout/search/search.component.ts @@ -8,6 +8,8 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { SearchBarService, SearchBarState } from "./search-bar.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-search", templateUrl: "search.component.html", 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 eb4e47e0ffd..f827dda9a9b 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 @@ -794,6 +794,9 @@ export class VaultComponent implements OnInit, OnDestroy { case "viewEvents": await this.viewEvents(event.item); break; + case "editCipher": + await this.editCipher(event.item); + break; } } finally { this.processingEvent$.next(false); @@ -856,7 +859,7 @@ export class VaultComponent implements OnInit, OnDestroy { * @param cipherView - When set, the cipher to be edited * @param cloneCipher - `true` when the cipher should be cloned. */ - async editCipher(cipher: CipherView | undefined, cloneCipher: boolean) { + async editCipher(cipher: CipherView | undefined, cloneCipher?: boolean) { if ( cipher && cipher.reprompt !== 0 && diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.spec.ts index 615d2ece463..aef4dd00312 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.spec.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.spec.ts @@ -1,13 +1,17 @@ import { TestBed } from "@angular/core/testing"; +import { of } from "rxjs"; import { + CollectionService, OrganizationUserApiService, OrganizationUserUserDetailsResponse, } from "@bitwarden/admin-console/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { OrganizationId } from "@bitwarden/common/types/guid"; +import { KeyService } from "@bitwarden/key-management"; import { GroupApiService } from "../../../core"; @@ -18,6 +22,9 @@ describe("OrganizationMembersService", () => { let organizationUserApiService: jest.Mocked; let groupService: jest.Mocked; let apiService: jest.Mocked; + let keyService: jest.Mocked; + let accountService: jest.Mocked; + let collectionService: jest.Mocked; const mockOrganizationId = "org-123" as OrganizationId; @@ -51,6 +58,7 @@ describe("OrganizationMembersService", () => { const createMockCollection = (id: string, name: string) => ({ id, name, + organizationId: mockOrganizationId, }); beforeEach(() => { @@ -66,12 +74,27 @@ describe("OrganizationMembersService", () => { getCollections: jest.fn(), } as any; + keyService = { + orgKeys$: jest.fn(), + } as any; + + accountService = { + activeAccount$: of({ id: "user-123" } as any), + } as any; + + collectionService = { + decryptMany$: jest.fn(), + } as any; + TestBed.configureTestingModule({ providers: [ OrganizationMembersService, { provide: OrganizationUserApiService, useValue: organizationUserApiService }, { provide: GroupApiService, useValue: groupService }, { provide: ApiService, useValue: apiService }, + { provide: KeyService, useValue: keyService }, + { provide: AccountService, useValue: accountService }, + { provide: CollectionService, useValue: collectionService }, ], }); @@ -88,11 +111,15 @@ describe("OrganizationMembersService", () => { data: [mockUser], } as any; const mockCollections = [createMockCollection("col-1", "Collection 1")]; + const mockOrgKey = { [mockOrganizationId]: {} as any }; + const mockDecryptedCollections = [{ id: "col-1", name: "Collection 1" }]; organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse); apiService.getCollections.mockResolvedValue({ data: mockCollections, } as any); + keyService.orgKeys$.mockReturnValue(of(mockOrgKey)); + collectionService.decryptMany$.mockReturnValue(of(mockDecryptedCollections as any)); const result = await service.loadUsers(organization); @@ -171,11 +198,19 @@ describe("OrganizationMembersService", () => { createMockCollection("col-2", "Alpha Collection"), createMockCollection("col-3", "Beta Collection"), ]; + const mockOrgKey = { [mockOrganizationId]: {} as any }; + const mockDecryptedCollections = [ + { id: "col-1", name: "Zebra Collection" }, + { id: "col-2", name: "Alpha Collection" }, + { id: "col-3", name: "Beta Collection" }, + ]; organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse); apiService.getCollections.mockResolvedValue({ data: mockCollections, } as any); + keyService.orgKeys$.mockReturnValue(of(mockOrgKey)); + collectionService.decryptMany$.mockReturnValue(of(mockDecryptedCollections as any)); const result = await service.loadUsers(organization); @@ -223,11 +258,19 @@ describe("OrganizationMembersService", () => { // col-2 is missing - should be filtered out createMockCollection("col-3", "Collection 3"), ]; + const mockOrgKey = { [mockOrganizationId]: {} as any }; + const mockDecryptedCollections = [ + { id: "col-1", name: "Collection 1" }, + // col-2 is missing - should be filtered out + { id: "col-3", name: "Collection 3" }, + ]; organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse); apiService.getCollections.mockResolvedValue({ data: mockCollections, } as any); + keyService.orgKeys$.mockReturnValue(of(mockOrgKey)); + collectionService.decryptMany$.mockReturnValue(of(mockDecryptedCollections as any)); const result = await service.loadUsers(organization); @@ -269,11 +312,14 @@ describe("OrganizationMembersService", () => { const mockUsersResponse: ListResponse = { data: null as any, } as any; + const mockOrgKey = { [mockOrganizationId]: {} as any }; organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse); apiService.getCollections.mockResolvedValue({ data: [], } as any); + keyService.orgKeys$.mockReturnValue(of(mockOrgKey)); + collectionService.decryptMany$.mockReturnValue(of([])); const result = await service.loadUsers(organization); @@ -285,11 +331,14 @@ describe("OrganizationMembersService", () => { const mockUsersResponse: ListResponse = { data: undefined as any, } as any; + const mockOrgKey = { [mockOrganizationId]: {} as any }; organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse); apiService.getCollections.mockResolvedValue({ data: [], } as any); + keyService.orgKeys$.mockReturnValue(of(mockOrgKey)); + collectionService.decryptMany$.mockReturnValue(of([])); const result = await service.loadUsers(organization); @@ -322,11 +371,14 @@ describe("OrganizationMembersService", () => { const mockUsersResponse: ListResponse = { data: [mockUser], } as any; + const mockOrgKey = { [mockOrganizationId]: {} as any }; organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse); apiService.getCollections.mockResolvedValue({ data: [], } as any); + keyService.orgKeys$.mockReturnValue(of(mockOrgKey)); + collectionService.decryptMany$.mockReturnValue(of([])); const result = await service.loadUsers(organization); diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.ts b/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.ts index 613c7c1b9c0..0dc417cc2c6 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.ts @@ -1,8 +1,18 @@ import { Injectable } from "@angular/core"; +import { combineLatest, firstValueFrom, from, map, switchMap } from "rxjs"; -import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; +import { + Collection, + CollectionData, + CollectionDetailsResponse, + CollectionService, + OrganizationUserApiService, +} from "@bitwarden/admin-console/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { KeyService } from "@bitwarden/key-management"; import { GroupApiService } from "../../../core"; import { OrganizationUserView } from "../../../core/views/organization-user.view"; @@ -13,6 +23,9 @@ export class OrganizationMembersService { private organizationUserApiService: OrganizationUserApiService, private groupService: GroupApiService, private apiService: ApiService, + private keyService: KeyService, + private accountService: AccountService, + private collectionService: CollectionService, ) {} async loadUsers(organization: Organization): Promise { @@ -62,15 +75,38 @@ export class OrganizationMembersService { } private async getCollectionNameMap(organization: Organization): Promise> { - const response = this.apiService - .getCollections(organization.id) - .then((res) => - res.data.map((r: { id: string; name: string }) => ({ id: r.id, name: r.name })), - ); + const collections$ = from(this.apiService.getCollections(organization.id)).pipe( + map((response) => { + return response.data.map((r) => + Collection.fromCollectionData(new CollectionData(r as CollectionDetailsResponse)), + ); + }), + ); - const collections = await response; - const collectionMap = new Map(); - collections.forEach((c: { id: string; name: string }) => collectionMap.set(c.id, c.name)); - return collectionMap; + const orgKey$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.keyService.orgKeys$(userId)), + map((orgKeys) => { + if (orgKeys == null) { + throw new Error("Organization keys not found for provided User."); + } + return orgKeys; + }), + ); + + return await firstValueFrom( + combineLatest([orgKey$, collections$]).pipe( + switchMap(([orgKey, collections]) => + this.collectionService.decryptMany$(collections, orgKey), + ), + map((decryptedCollections) => { + const collectionMap: Map = new Map(); + decryptedCollections.forEach((c) => { + collectionMap.set(c.id, c.name); + }); + return collectionMap; + }), + ), + ); } } diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 60911173308..13c4207992c 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -33,6 +33,8 @@ import { KeyService, BiometricStateService } from "@bitwarden/key-management"; const BroadcasterSubscriptionId = "AppComponent"; const IdleTimeout = 60000 * 10; // 10 minutes +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-root", templateUrl: "app.component.html", diff --git a/apps/web/src/app/billing/individual/premium/premium-vnext.component.html b/apps/web/src/app/billing/individual/premium/premium-vnext.component.html index bf5d0f60861..ee2bef9baa3 100644 --- a/apps/web/src/app/billing/individual/premium/premium-vnext.component.html +++ b/apps/web/src/app/billing/individual/premium/premium-vnext.component.html @@ -38,7 +38,7 @@ diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts index 27e69fcf0d4..a6038873e83 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts @@ -98,7 +98,7 @@ describe("UpgradeAccountComponent", () => { expect(sut["familiesCardDetails"].price.amount).toBe(40 / 12); expect(sut["familiesCardDetails"].price.cadence).toBe("monthly"); expect(sut["familiesCardDetails"].button.type).toBe("secondary"); - expect(sut["familiesCardDetails"].button.text).toBe("upgradeToFamilies"); + expect(sut["familiesCardDetails"].button.text).toBe("startFreeFamiliesTrial"); expect(sut["familiesCardDetails"].features).toEqual(["Feature A", "Feature B", "Feature C"]); }); diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts index be09505d190..780b6bed433 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts @@ -119,7 +119,7 @@ export class UpgradeAccountComponent implements OnInit { }, button: { text: this.i18nService.t( - this.isFamiliesPlan(tier.id) ? "upgradeToFamilies" : "upgradeToPremium", + this.isFamiliesPlan(tier.id) ? "startFreeFamiliesTrial" : "upgradeToPremium", ), type: buttonType, }, diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts index a0ba480fe1e..a80ff5d720a 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts @@ -161,7 +161,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { }; this.upgradeToMessage = this.i18nService.t( - this.isFamiliesPlan ? "upgradeToFamilies" : "upgradeToPremium", + this.isFamiliesPlan ? "startFreeFamiliesTrial" : "upgradeToPremium", ); } else { this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null }); diff --git a/apps/web/src/app/billing/services/free-families-policy.service.spec.ts b/apps/web/src/app/billing/services/free-families-policy.service.spec.ts index 10ccc448986..5b39a5a848a 100644 --- a/apps/web/src/app/billing/services/free-families-policy.service.spec.ts +++ b/apps/web/src/app/billing/services/free-families-policy.service.spec.ts @@ -38,7 +38,6 @@ describe("FreeFamiliesPolicyService", () => { describe("showSponsoredFamiliesDropdown$", () => { it("should return true when all conditions are met", async () => { // Configure mocks - configService.getFeatureFlag$.mockReturnValue(of(true)); policyService.policiesByType$.mockReturnValue(of([])); // Create a test organization that meets all criteria @@ -58,7 +57,6 @@ describe("FreeFamiliesPolicyService", () => { it("should return false when organization is not Enterprise", async () => { // Configure mocks - configService.getFeatureFlag$.mockReturnValue(of(true)); policyService.policiesByType$.mockReturnValue(of([])); // Create a test organization that is not Enterprise tier @@ -74,27 +72,8 @@ describe("FreeFamiliesPolicyService", () => { expect(result).toBe(false); }); - it("should return false when feature flag is disabled", async () => { - // Configure mocks to disable feature flag - configService.getFeatureFlag$.mockReturnValue(of(false)); - policyService.policiesByType$.mockReturnValue(of([])); - - // Create a test organization that meets other criteria - const organization = { - id: "org-id", - productTierType: ProductTierType.Enterprise, - useAdminSponsoredFamilies: true, - isAdmin: true, - } as Organization; - - // Test the method - const result = await firstValueFrom(service.showSponsoredFamiliesDropdown$(of(organization))); - expect(result).toBe(false); - }); - it("should return false when families feature is disabled by policy", async () => { // Configure mocks with a policy that disables the feature - configService.getFeatureFlag$.mockReturnValue(of(true)); policyService.policiesByType$.mockReturnValue( of([{ organizationId: "org-id", enabled: true } as Policy]), ); @@ -114,7 +93,6 @@ describe("FreeFamiliesPolicyService", () => { it("should return false when useAdminSponsoredFamilies is false", async () => { // Configure mocks - configService.getFeatureFlag$.mockReturnValue(of(true)); policyService.policiesByType$.mockReturnValue(of([])); // Create a test organization with useAdminSponsoredFamilies set to false @@ -132,7 +110,6 @@ describe("FreeFamiliesPolicyService", () => { it("should return true when user is an owner but not admin", async () => { // Configure mocks - configService.getFeatureFlag$.mockReturnValue(of(true)); policyService.policiesByType$.mockReturnValue(of([])); // Create a test organization where user is owner but not admin @@ -152,7 +129,6 @@ describe("FreeFamiliesPolicyService", () => { it("should return true when user can manage users but is not admin or owner", async () => { // Configure mocks - configService.getFeatureFlag$.mockReturnValue(of(true)); policyService.policiesByType$.mockReturnValue(of([])); // Create a test organization where user can manage users but is not admin or owner @@ -172,7 +148,6 @@ describe("FreeFamiliesPolicyService", () => { it("should return false when user has no admin permissions", async () => { // Configure mocks - configService.getFeatureFlag$.mockReturnValue(of(true)); policyService.policiesByType$.mockReturnValue(of([])); // Create a test organization where user has no admin permissions diff --git a/apps/web/src/app/billing/services/free-families-policy.service.ts b/apps/web/src/app/billing/services/free-families-policy.service.ts index 52041936e50..68e333d53ba 100644 --- a/apps/web/src/app/billing/services/free-families-policy.service.ts +++ b/apps/web/src/app/billing/services/free-families-policy.service.ts @@ -8,8 +8,6 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ProductTierType } from "@bitwarden/common/billing/enums"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; interface EnterpriseOrgStatus { isFreeFamilyPolicyEnabled: boolean; @@ -23,7 +21,6 @@ export class FreeFamiliesPolicyService { private policyService: PolicyService, private organizationService: OrganizationService, private accountService: AccountService, - private configService: ConfigService, ) {} organizations$ = this.accountService.activeAccount$.pipe( @@ -58,20 +55,14 @@ export class FreeFamiliesPolicyService { userId, ); - return combineLatest([ - enterpriseOrganization$, - this.configService.getFeatureFlag$(FeatureFlag.PM17772_AdminInitiatedSponsorships), - organization, - policies$, - ]).pipe( - map(([isEnterprise, featureFlagEnabled, org, policies]) => { + return combineLatest([enterpriseOrganization$, organization, policies$]).pipe( + map(([isEnterprise, org, policies]) => { const familiesFeatureDisabled = policies.some( (policy) => policy.organizationId === org.id && policy.enabled, ); return ( isEnterprise && - featureFlagEnabled && !familiesFeatureDisabled && org.useAdminSponsoredFamilies && (org.isAdmin || org.isOwner || org.canManageUsers) diff --git a/apps/web/src/app/components/dynamic-avatar.component.ts b/apps/web/src/app/components/dynamic-avatar.component.ts index 8cd73862151..ddaaa21758b 100644 --- a/apps/web/src/app/components/dynamic-avatar.component.ts +++ b/apps/web/src/app/components/dynamic-avatar.component.ts @@ -8,6 +8,8 @@ import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.servic import { SharedModule } from "../shared"; type SizeTypes = "xlarge" | "large" | "default" | "small" | "xsmall"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "dynamic-avatar", imports: [SharedModule], @@ -25,10 +27,20 @@ type SizeTypes = "xlarge" | "large" | "default" | "small" | "xsmall"; `, }) export class DynamicAvatarComponent implements OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() border = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() id: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() text: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() title: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() size: SizeTypes = "default"; private destroy$ = new Subject(); diff --git a/apps/web/src/app/components/environment-selector/environment-selector.component.ts b/apps/web/src/app/components/environment-selector/environment-selector.component.ts index 37e5ae0c3d8..4f77cc96bf7 100644 --- a/apps/web/src/app/components/environment-selector/environment-selector.component.ts +++ b/apps/web/src/app/components/environment-selector/environment-selector.component.ts @@ -12,6 +12,8 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SharedModule } from "../../shared"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "environment-selector", templateUrl: "environment-selector.component.html", diff --git a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.spec.ts b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.spec.ts index acc34232571..80893737ffd 100644 --- a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.spec.ts +++ b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.spec.ts @@ -121,4 +121,153 @@ describe("InactiveTwoFactorReportComponent", () => { it("should call fullSync method of syncService", () => { expect(syncServiceMock.fullSync).toHaveBeenCalledWith(false); }); + + describe("isInactive2faCipher", () => { + beforeEach(() => { + // Add both domain and host to services map + component.services.set("example.com", "https://example.com/2fa-doc"); + component.services.set("sub.example.com", "https://sub.example.com/2fa-doc"); + fixture.detectChanges(); + }); + it("should return true and documentation for cipher with matching domain", () => { + const cipher = createCipherView({ + login: { + uris: [{ uri: "https://example.com/login" }], + }, + }); + const [doc, isInactive] = (component as any).isInactive2faCipher(cipher); + expect(isInactive).toBe(true); + expect(doc).toBe("https://example.com/2fa-doc"); + }); + + it("should return true and documentation for cipher with matching host", () => { + const cipher = createCipherView({ + login: { + uris: [{ uri: "https://sub.example.com/login" }], + }, + }); + const [doc, isInactive] = (component as any).isInactive2faCipher(cipher); + expect(isInactive).toBe(true); + expect(doc).toBe("https://sub.example.com/2fa-doc"); + }); + + it("should return false for cipher with non-matching domain or host", () => { + const cipher = createCipherView({ + login: { + uris: [{ uri: "https://otherdomain.com/login" }], + }, + }); + const [doc, isInactive] = (component as any).isInactive2faCipher(cipher); + expect(isInactive).toBe(false); + expect(doc).toBe(""); + }); + + it("should return false if cipher type is not Login", () => { + const cipher = createCipherView({ + type: 2, + login: { + uris: [{ uri: "https://example.com/login" }], + }, + }); + const [doc, isInactive] = (component as any).isInactive2faCipher(cipher); + expect(isInactive).toBe(false); + expect(doc).toBe(""); + }); + + it("should return false if cipher has TOTP", () => { + const cipher = createCipherView({ + login: { + totp: "some-totp", + uris: [{ uri: "https://example.com/login" }], + }, + }); + const [doc, isInactive] = (component as any).isInactive2faCipher(cipher); + expect(isInactive).toBe(false); + expect(doc).toBe(""); + }); + + it("should return false if cipher is deleted", () => { + const cipher = createCipherView({ + isDeleted: true, + login: { + uris: [{ uri: "https://example.com/login" }], + }, + }); + const [doc, isInactive] = (component as any).isInactive2faCipher(cipher); + expect(isInactive).toBe(false); + expect(doc).toBe(""); + }); + + it("should return false if cipher does not have edit access and no organization", () => { + component.organization = null; + const cipher = createCipherView({ + edit: false, + login: { + uris: [{ uri: "https://example.com/login" }], + }, + }); + const [doc, isInactive] = (component as any).isInactive2faCipher(cipher); + expect(isInactive).toBe(false); + expect(doc).toBe(""); + }); + + it("should return false if cipher does not have viewPassword", () => { + const cipher = createCipherView({ + viewPassword: false, + login: { + uris: [{ uri: "https://example.com/login" }], + }, + }); + const [doc, isInactive] = (component as any).isInactive2faCipher(cipher); + expect(isInactive).toBe(false); + expect(doc).toBe(""); + }); + + it("should check all uris and return true if any matches domain or host", () => { + const cipher = createCipherView({ + login: { + uris: [ + { uri: "https://otherdomain.com/login" }, + { uri: "https://sub.example.com/dashboard" }, + ], + }, + }); + const [doc, isInactive] = (component as any).isInactive2faCipher(cipher); + expect(isInactive).toBe(true); + expect(doc).toBe("https://sub.example.com/2fa-doc"); + }); + + it("should return false if uris array is empty", () => { + const cipher = createCipherView({ + login: { + uris: [], + }, + }); + const [doc, isInactive] = (component as any).isInactive2faCipher(cipher); + expect(isInactive).toBe(false); + expect(doc).toBe(""); + }); + + function createCipherView({ + type = 1, + login = {}, + isDeleted = false, + edit = true, + viewPassword = true, + }: any): any { + return { + id: "test-id", + type, + login: { + totp: null, + hasUris: true, + uris: [], + ...login, + }, + isDeleted, + edit, + viewPassword, + }; + } + }); }); diff --git a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.ts b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.ts index 8b0fdda70e3..2a8ec12ac6a 100644 --- a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.ts @@ -109,7 +109,18 @@ export class InactiveTwoFactorReportComponent extends CipherReportComponent impl const u = login.uris[i]; if (u.uri != null && u.uri !== "") { const uri = u.uri.replace("www.", ""); + const host = Utils.getHost(uri); const domain = Utils.getDomain(uri); + // check host first + if (host != null && this.services.has(host)) { + if (this.services.get(host) != null) { + docFor2fa = this.services.get(host) || ""; + } + isInactive2faCipher = true; + break; + } + + // then check domain if (domain != null && this.services.has(domain)) { if (this.services.get(domain) != null) { docFor2fa = this.services.get(domain) || ""; diff --git a/apps/web/src/app/secrets-manager/secrets-manager-landing/request-sm-access.component.ts b/apps/web/src/app/secrets-manager/secrets-manager-landing/request-sm-access.component.ts index 0e32321a0b3..afac3b059a8 100644 --- a/apps/web/src/app/secrets-manager/secrets-manager-landing/request-sm-access.component.ts +++ b/apps/web/src/app/secrets-manager/secrets-manager-landing/request-sm-access.component.ts @@ -19,6 +19,8 @@ import { RequestSMAccessRequest } from "../models/requests/request-sm-access.req import { SmLandingApiService } from "./sm-landing-api.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-request-sm-access", templateUrl: "request-sm-access.component.html", diff --git a/apps/web/src/app/secrets-manager/secrets-manager-landing/sm-landing.component.ts b/apps/web/src/app/secrets-manager/secrets-manager-landing/sm-landing.component.ts index 301e6f7dfad..c1cc2b63e28 100644 --- a/apps/web/src/app/secrets-manager/secrets-manager-landing/sm-landing.component.ts +++ b/apps/web/src/app/secrets-manager/secrets-manager-landing/sm-landing.component.ts @@ -12,6 +12,8 @@ import { NoItemsModule, SearchModule } from "@bitwarden/components"; import { HeaderModule } from "../../layouts/header/header.module"; import { SharedModule } from "../../shared/shared.module"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-sm-landing", imports: [SharedModule, SearchModule, NoItemsModule, HeaderModule], diff --git a/apps/web/src/app/settings/domain-rules.component.ts b/apps/web/src/app/settings/domain-rules.component.ts index 6c4cb13d5fa..0e9d2f422d9 100644 --- a/apps/web/src/app/settings/domain-rules.component.ts +++ b/apps/web/src/app/settings/domain-rules.component.ts @@ -12,6 +12,8 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { HeaderModule } from "../layouts/header/header.module"; import { SharedModule } from "../shared"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-domain-rules", templateUrl: "domain-rules.component.html", diff --git a/apps/web/src/app/settings/preferences.component.ts b/apps/web/src/app/settings/preferences.component.ts index 58a072ce76a..c1e8fce98ca 100644 --- a/apps/web/src/app/settings/preferences.component.ts +++ b/apps/web/src/app/settings/preferences.component.ts @@ -39,6 +39,8 @@ import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault"; import { HeaderModule } from "../layouts/header/header.module"; import { SharedModule } from "../shared"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-preferences", templateUrl: "preferences.component.html", diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html index 43ce8530d55..c09553dab9c 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html @@ -169,10 +169,12 @@ - + @if (!viewingOrgVault) { + + } + + + diff --git a/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.spec.ts b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.spec.ts new file mode 100644 index 00000000000..332a0e323a7 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.spec.ts @@ -0,0 +1,79 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { mock } from "jest-mock-extended"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DialogRef, DialogService } from "@bitwarden/components"; + +import { SessionTimeoutConfirmationNeverComponent } from "./session-timeout-confirmation-never.component"; + +describe("SessionTimeoutConfirmationNeverComponent", () => { + let component: SessionTimeoutConfirmationNeverComponent; + let fixture: ComponentFixture; + let mockDialogRef: jest.Mocked; + + const mockI18nService = mock(); + const mockDialogService = mock(); + + beforeEach(async () => { + mockDialogRef = mock(); + mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`); + + await TestBed.configureTestingModule({ + imports: [SessionTimeoutConfirmationNeverComponent, NoopAnimationsModule], + providers: [ + { provide: DialogRef, useValue: mockDialogRef }, + { provide: I18nService, useValue: mockI18nService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SessionTimeoutConfirmationNeverComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("open", () => { + it("should call dialogService.open with correct parameters", () => { + const mockResult = mock(); + mockDialogService.open.mockReturnValue(mockResult); + + const result = SessionTimeoutConfirmationNeverComponent.open(mockDialogService); + + expect(mockDialogService.open).toHaveBeenCalledWith( + SessionTimeoutConfirmationNeverComponent, + { + disableClose: true, + }, + ); + expect(result).toBe(mockResult); + }); + }); + + describe("button clicks", () => { + it("should close dialog with true when Yes button is clicked", () => { + const yesButton = fixture.nativeElement.querySelector( + 'button[buttonType="primary"]', + ) as HTMLButtonElement; + + yesButton.click(); + + expect(mockDialogRef.close).toHaveBeenCalledWith(true); + expect(yesButton.textContent?.trim()).toBe("yes-used-i18n"); + }); + + it("should close dialog with false when No button is clicked", () => { + const noButton = fixture.nativeElement.querySelector( + 'button[buttonType="secondary"]', + ) as HTMLButtonElement; + + noButton.click(); + + expect(mockDialogRef.close).toHaveBeenCalledWith(false); + expect(noButton.textContent?.trim()).toBe("no-used-i18n"); + }); + }); +}); diff --git a/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.ts b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.ts new file mode 100644 index 00000000000..884cbd10cac --- /dev/null +++ b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.ts @@ -0,0 +1,20 @@ +import { Component } from "@angular/core"; + +import { DialogRef, DialogService } from "@bitwarden/components"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + imports: [SharedModule], + templateUrl: "./session-timeout-confirmation-never.component.html", +}) +export class SessionTimeoutConfirmationNeverComponent { + constructor(public dialogRef: DialogRef) {} + + static open(dialogService: DialogService) { + return dialogService.open(SessionTimeoutConfirmationNeverComponent, { + disableClose: true, + }); + } +} diff --git a/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.html b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.html new file mode 100644 index 00000000000..22e9e07bea7 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.html @@ -0,0 +1,39 @@ + + {{ "requireSsoPolicyReq" | i18n }} + + + + + {{ "turnOn" | i18n }} + + +
+
+ + {{ "maximumAllowedTimeout" | i18n }} + + @for (option of typeOptions; track option.value) { + + } + + + @if (data.value.type === "custom") { + + {{ "hours" | i18n }} + + + + {{ "minutes" | i18n }} + + + } + + {{ "sessionTimeoutAction" | i18n }} + + @for (option of actionOptions; track option.value) { + + } + + +
+
diff --git a/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.spec.ts b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.spec.ts new file mode 100644 index 00000000000..694b0f1d1a2 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.spec.ts @@ -0,0 +1,441 @@ +import { DialogCloseOptions } from "@angular/cdk/dialog"; +import { DebugElement } from "@angular/core"; +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; +import { By } from "@angular/platform-browser"; +import { mock } from "jest-mock-extended"; +import { Observable, of } from "rxjs"; + +import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response"; +import { VaultTimeoutAction } from "@bitwarden/common/key-management/vault-timeout"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DialogRef, DialogService } from "@bitwarden/components"; + +import { SessionTimeoutConfirmationNeverComponent } from "./session-timeout-confirmation-never.component"; +import { + SessionTimeoutAction, + SessionTimeoutPolicyComponent, + SessionTimeoutType, +} from "./session-timeout.component"; + +// Mock DialogRef, so we can mock "readonly closed" property. +class MockDialogRef extends DialogRef { + close(result: unknown | undefined, options: DialogCloseOptions | undefined): void {} + + closed: Observable = of(); + componentInstance: unknown | null; + disableClose: boolean | undefined; + isDrawer: boolean = false; +} + +describe("SessionTimeoutPolicyComponent", () => { + let component: SessionTimeoutPolicyComponent; + let fixture: ComponentFixture; + + const mockI18nService = mock(); + const mockDialogService = mock(); + const mockDialogRef = mock(); + + beforeEach(async () => { + jest.resetAllMocks(); + + mockDialogRef.closed = of(true); + mockDialogService.open.mockReturnValue(mockDialogRef); + mockDialogService.openSimpleDialog.mockResolvedValue(true); + + mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`); + + const testBed = TestBed.configureTestingModule({ + imports: [SessionTimeoutPolicyComponent, ReactiveFormsModule], + providers: [FormBuilder, { provide: I18nService, useValue: mockI18nService }], + }); + + // Override DialogService provided from SharedModule (which includes DialogModule) + testBed.overrideProvider(DialogService, { useValue: mockDialogService }); + + await testBed.compileComponents(); + + fixture = TestBed.createComponent(SessionTimeoutPolicyComponent); + component = fixture.componentInstance; + }); + + function assertHoursAndMinutesInputsNotVisible() { + const hoursInput = fixture.nativeElement.querySelector('input[formControlName="hours"]'); + const minutesInput = fixture.nativeElement.querySelector('input[formControlName="minutes"]'); + + expect(hoursInput).toBeFalsy(); + expect(minutesInput).toBeFalsy(); + } + + function assertHoursAndMinutesInputs(expectedHours: string, expectedMinutes: string) { + const hoursInput = fixture.nativeElement.querySelector('input[formControlName="hours"]'); + const minutesInput = fixture.nativeElement.querySelector('input[formControlName="minutes"]'); + + expect(hoursInput).toBeTruthy(); + expect(minutesInput).toBeTruthy(); + expect(hoursInput.disabled).toBe(false); + expect(minutesInput.disabled).toBe(false); + expect(hoursInput.value).toBe(expectedHours); + expect(minutesInput.value).toBe(expectedMinutes); + } + + function setPolicyResponseType(type: SessionTimeoutType) { + component.policyResponse = new PolicyResponse({ + Data: { + type, + minutes: 480, + action: null, + }, + }); + } + + describe("initialization and data loading", () => { + function assertTypeAndActionSelectElementsVisible() { + // Type and action selects should always be present + const typeSelectDebug: DebugElement = fixture.debugElement.query( + By.css('bit-select[formControlName="type"]'), + ); + const actionSelectDebug: DebugElement = fixture.debugElement.query( + By.css('bit-select[formControlName="action"]'), + ); + + expect(typeSelectDebug).toBeTruthy(); + expect(actionSelectDebug).toBeTruthy(); + } + + it("should initialize with default state when policy have no value", () => { + component.policyResponse = undefined; + + fixture.detectChanges(); + + expect(component.data.controls.type.value).toBeNull(); + expect(component.data.controls.type.hasError("required")).toBe(true); + expect(component.data.controls.hours.value).toBe(8); + expect(component.data.controls.hours.disabled).toBe(true); + expect(component.data.controls.minutes.value).toBe(0); + expect(component.data.controls.minutes.disabled).toBe(true); + expect(component.data.controls.action.value).toBeNull(); + + assertTypeAndActionSelectElementsVisible(); + assertHoursAndMinutesInputsNotVisible(); + }); + + // This is for backward compatibility when type field did not exist + it("should load as custom type when type field does not exist but minutes does", () => { + component.policyResponse = new PolicyResponse({ + Data: { + minutes: 500, + action: VaultTimeoutAction.Lock, + }, + }); + + fixture.detectChanges(); + + expect(component.data.controls.type.value).toBe("custom"); + expect(component.data.controls.hours.value).toBe(8); + expect(component.data.controls.hours.disabled).toBe(false); + expect(component.data.controls.minutes.value).toBe(20); + expect(component.data.controls.minutes.disabled).toBe(false); + expect(component.data.controls.action.value).toBe(VaultTimeoutAction.Lock); + + assertTypeAndActionSelectElementsVisible(); + assertHoursAndMinutesInputs("8", "20"); + }); + + it.each([ + ["never", null], + ["never", VaultTimeoutAction.Lock], + ["never", VaultTimeoutAction.LogOut], + ["onAppRestart", null], + ["onAppRestart", VaultTimeoutAction.Lock], + ["onAppRestart", VaultTimeoutAction.LogOut], + ["onSystemLock", null], + ["onSystemLock", VaultTimeoutAction.Lock], + ["onSystemLock", VaultTimeoutAction.LogOut], + ["immediately", null], + ["immediately", VaultTimeoutAction.Lock], + ["immediately", VaultTimeoutAction.LogOut], + ["custom", null], + ["custom", VaultTimeoutAction.Lock], + ["custom", VaultTimeoutAction.LogOut], + ])("should load correctly when policy type is %s and action is %s", (type, action) => { + component.policyResponse = new PolicyResponse({ + Data: { + type, + minutes: 510, + action, + }, + }); + + fixture.detectChanges(); + + expect(component.data.controls.type.value).toBe(type); + expect(component.data.controls.action.value).toBe(action); + + assertTypeAndActionSelectElementsVisible(); + + if (type === "custom") { + expect(component.data.controls.hours.value).toBe(8); + expect(component.data.controls.minutes.value).toBe(30); + expect(component.data.controls.hours.disabled).toBe(false); + expect(component.data.controls.minutes.disabled).toBe(false); + + assertHoursAndMinutesInputs("8", "30"); + } else { + expect(component.data.controls.hours.disabled).toBe(true); + expect(component.data.controls.minutes.disabled).toBe(true); + + assertHoursAndMinutesInputsNotVisible(); + } + }); + + it("should have all type options and update form control when value changes", fakeAsync(() => { + expect(component.typeOptions.length).toBe(5); + expect(component.typeOptions[0].value).toBe("immediately"); + expect(component.typeOptions[1].value).toBe("custom"); + expect(component.typeOptions[2].value).toBe("onSystemLock"); + expect(component.typeOptions[3].value).toBe("onAppRestart"); + expect(component.typeOptions[4].value).toBe("never"); + })); + + it("should have all action options and update form control when value changes", () => { + expect(component.actionOptions.length).toBe(3); + expect(component.actionOptions[0].value).toBeNull(); + expect(component.actionOptions[1].value).toBe(VaultTimeoutAction.Lock); + expect(component.actionOptions[2].value).toBe(VaultTimeoutAction.LogOut); + }); + }); + + describe("form controls change detection", () => { + it.each(["never", "onAppRestart", "onSystemLock", "immediately"])( + "should disable hours and minutes inputs when type changes from custom to %s", + fakeAsync((newType: SessionTimeoutType) => { + setPolicyResponseType("custom"); + fixture.detectChanges(); + + expect(component.data.controls.hours.value).toBe(8); + expect(component.data.controls.minutes.value).toBe(0); + expect(component.data.controls.hours.disabled).toBe(false); + expect(component.data.controls.minutes.disabled).toBe(false); + + component.data.patchValue({ type: newType }); + tick(); + fixture.detectChanges(); + + expect(component.data.controls.hours.disabled).toBe(true); + expect(component.data.controls.minutes.disabled).toBe(true); + + assertHoursAndMinutesInputsNotVisible(); + }), + ); + + it.each(["never", "onAppRestart", "onSystemLock", "immediately"])( + "should enable hours and minutes inputs when type changes from %s to custom", + fakeAsync((oldType: SessionTimeoutType) => { + setPolicyResponseType(oldType); + fixture.detectChanges(); + + expect(component.data.controls.hours.disabled).toBe(true); + expect(component.data.controls.minutes.disabled).toBe(true); + + component.data.patchValue({ type: "custom", hours: 8, minutes: 1 }); + tick(); + fixture.detectChanges(); + + expect(component.data.controls.hours.value).toBe(8); + expect(component.data.controls.minutes.value).toBe(1); + expect(component.data.controls.hours.disabled).toBe(false); + expect(component.data.controls.minutes.disabled).toBe(false); + + assertHoursAndMinutesInputs("8", "1"); + }), + ); + + it.each(["custom", "onAppRestart", "immediately"])( + "should not show confirmation dialog when changing to %s type", + fakeAsync((newType: SessionTimeoutType) => { + setPolicyResponseType(null); + fixture.detectChanges(); + + component.data.patchValue({ type: newType }); + tick(); + fixture.detectChanges(); + + expect(mockDialogService.open).not.toHaveBeenCalled(); + expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled(); + }), + ); + + it("should show never confirmation dialog when changing to never type", fakeAsync(() => { + setPolicyResponseType(null); + fixture.detectChanges(); + + component.data.patchValue({ type: "never" }); + tick(); + fixture.detectChanges(); + + expect(mockDialogService.open).toHaveBeenCalledWith( + SessionTimeoutConfirmationNeverComponent, + { + disableClose: true, + }, + ); + expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled(); + })); + + it("should show simple confirmation dialog when changing to onSystemLock type", fakeAsync(() => { + setPolicyResponseType(null); + fixture.detectChanges(); + + component.data.patchValue({ type: "onSystemLock" }); + tick(); + fixture.detectChanges(); + + expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({ + type: "info", + title: { key: "sessionTimeoutConfirmationOnSystemLockTitle" }, + content: { key: "sessionTimeoutConfirmationOnSystemLockDescription" }, + acceptButtonText: { key: "continue" }, + cancelButtonText: { key: "cancel" }, + }); + expect(mockDialogService.open).not.toHaveBeenCalled(); + expect(component.data.controls.type.value).toBe("onSystemLock"); + })); + + it("should revert to previous type when type changed to never and dialog not confirmed", fakeAsync(() => { + mockDialogRef.closed = of(false); + setPolicyResponseType("immediately"); + fixture.detectChanges(); + + component.data.patchValue({ type: "never" }); + tick(); + fixture.detectChanges(); + + expect(mockDialogService.open).toHaveBeenCalled(); + expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled(); + expect(component.data.controls.type.value).toBe("immediately"); + })); + + it("should revert to previous type when type changed to onSystemLock and dialog not confirmed", fakeAsync(() => { + mockDialogService.openSimpleDialog.mockResolvedValue(false); + setPolicyResponseType("immediately"); + fixture.detectChanges(); + + component.data.patchValue({ type: "onSystemLock" }); + tick(); + fixture.detectChanges(); + + expect(mockDialogService.openSimpleDialog).toHaveBeenCalled(); + expect(mockDialogService.open).not.toHaveBeenCalled(); + expect(component.data.controls.type.value).toBe("immediately"); + })); + + it("should revert to last confirmed type when canceling multiple times", fakeAsync(() => { + mockDialogRef.closed = of(false); + mockDialogService.openSimpleDialog.mockResolvedValue(false); + + setPolicyResponseType("custom"); + fixture.detectChanges(); + + // First attempt: custom -> never (cancel) + component.data.patchValue({ type: "never" }); + tick(); + fixture.detectChanges(); + + expect(component.data.controls.type.value).toBe("custom"); + + // Second attempt: custom -> onSystemLock (cancel) + component.data.patchValue({ type: "onSystemLock" }); + tick(); + fixture.detectChanges(); + + // Should revert to "custom", not "never" + expect(component.data.controls.type.value).toBe("custom"); + })); + }); + + describe("buildRequestData", () => { + beforeEach(() => { + setPolicyResponseType("custom"); + fixture.detectChanges(); + }); + + it("should throw max allowed timeout required error when type is invalid", () => { + component.data.patchValue({ type: null }); + + expect(() => component["buildRequestData"]()).toThrow( + "maximumAllowedTimeoutRequired-used-i18n", + ); + }); + + it.each([ + [null, null], + [null, 0], + [0, null], + [0, 0], + ])( + "should throw invalid time error when type is custom, hours is %o and minutes is %o ", + (hours, minutes) => { + component.data.patchValue({ + type: "custom", + hours: hours, + minutes: minutes, + }); + + expect(() => component["buildRequestData"]()).toThrow( + "sessionTimeoutPolicyInvalidTime-used-i18n", + ); + }, + ); + + it("should return correct data when type is custom with valid time", () => { + component.data.patchValue({ + type: "custom", + hours: 8, + minutes: 30, + action: VaultTimeoutAction.Lock, + }); + + const result = component["buildRequestData"](); + + expect(result).toEqual({ + type: "custom", + minutes: 510, + action: VaultTimeoutAction.Lock, + }); + }); + + it.each([ + ["never", null], + ["never", VaultTimeoutAction.Lock], + ["never", VaultTimeoutAction.LogOut], + ["immediately", null], + ["immediately", VaultTimeoutAction.Lock], + ["immediately", VaultTimeoutAction.LogOut], + ["onSystemLock", null], + ["onSystemLock", VaultTimeoutAction.Lock], + ["onSystemLock", VaultTimeoutAction.LogOut], + ["onAppRestart", null], + ["onAppRestart", VaultTimeoutAction.Lock], + ["onAppRestart", VaultTimeoutAction.LogOut], + ])( + "should return default 8 hours for backward compatibility when type is %s and action is %s", + (type, action) => { + component.data.patchValue({ + type: type as SessionTimeoutType, + hours: 5, + minutes: 25, + action: action as SessionTimeoutAction, + }); + + const result = component["buildRequestData"](); + + expect(result).toEqual({ + type, + minutes: 480, + action, + }); + }, + ); + }); +}); diff --git a/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.ts b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.ts new file mode 100644 index 00000000000..9c6129f64df --- /dev/null +++ b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.ts @@ -0,0 +1,199 @@ +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { FormBuilder, FormControl, Validators } from "@angular/forms"; +import { + BehaviorSubject, + concatMap, + firstValueFrom, + Subject, + takeUntil, + withLatestFrom, +} from "rxjs"; + +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { VaultTimeoutAction } from "@bitwarden/common/key-management/vault-timeout"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DialogService } from "@bitwarden/components"; +import { + BasePolicyEditDefinition, + BasePolicyEditComponent, +} from "@bitwarden/web-vault/app/admin-console/organizations/policies"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { SessionTimeoutConfirmationNeverComponent } from "./session-timeout-confirmation-never.component"; + +export type SessionTimeoutAction = null | "lock" | "logOut"; +export type SessionTimeoutType = + | null + | "never" + | "onAppRestart" + | "onSystemLock" + | "immediately" + | "custom"; + +export class SessionTimeoutPolicy extends BasePolicyEditDefinition { + name = "sessionTimeoutPolicyTitle"; + description = "sessionTimeoutPolicyDescription"; + type = PolicyType.MaximumVaultTimeout; + component = SessionTimeoutPolicyComponent; +} + +const DEFAULT_HOURS = 8; +const DEFAULT_MINUTES = 0; + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + templateUrl: "session-timeout.component.html", + imports: [SharedModule], +}) +export class SessionTimeoutPolicyComponent + extends BasePolicyEditComponent + implements OnInit, OnDestroy +{ + private destroy$ = new Subject(); + private lastConfirmedType$ = new BehaviorSubject(null); + + actionOptions: { name: string; value: SessionTimeoutAction }[]; + typeOptions: { name: string; value: SessionTimeoutType }[]; + data = this.formBuilder.group({ + type: new FormControl(null, [Validators.required]), + hours: new FormControl( + { + value: DEFAULT_HOURS, + disabled: true, + }, + [Validators.required], + ), + minutes: new FormControl( + { + value: DEFAULT_MINUTES, + disabled: true, + }, + [Validators.required], + ), + action: new FormControl(null), + }); + + constructor( + private formBuilder: FormBuilder, + private i18nService: I18nService, + private dialogService: DialogService, + ) { + super(); + this.actionOptions = [ + { name: i18nService.t("userPreference"), value: null }, + { name: i18nService.t("lock"), value: VaultTimeoutAction.Lock }, + { name: i18nService.t("logOut"), value: VaultTimeoutAction.LogOut }, + ]; + this.typeOptions = [ + { name: i18nService.t("immediately"), value: "immediately" }, + { name: i18nService.t("custom"), value: "custom" }, + { name: i18nService.t("onSystemLock"), value: "onSystemLock" }, + { name: i18nService.t("onAppRestart"), value: "onAppRestart" }, + { name: i18nService.t("never"), value: "never" }, + ]; + } + + ngOnInit() { + super.ngOnInit(); + + const typeControl = this.data.controls.type; + this.lastConfirmedType$.next(typeControl.value ?? null); + + typeControl.valueChanges + .pipe( + withLatestFrom(this.lastConfirmedType$), + concatMap(async ([newType, lastConfirmedType]) => { + const confirmed = await this.confirmTypeChange(newType); + if (confirmed) { + this.updateFormControls(newType); + this.lastConfirmedType$.next(newType); + } else { + typeControl.setValue(lastConfirmedType, { emitEvent: false }); + } + }), + takeUntil(this.destroy$), + ) + .subscribe(); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + protected override loadData() { + const minutes: number | null = this.policyResponse?.data?.minutes ?? null; + const action: SessionTimeoutAction = + this.policyResponse?.data?.action ?? (null satisfies SessionTimeoutAction); + // For backward compatibility, the "type" field might not exist, hence we initialize it based on the presence of "minutes" + const type: SessionTimeoutType = + this.policyResponse?.data?.type ?? ((minutes ? "custom" : null) satisfies SessionTimeoutType); + + this.updateFormControls(type); + this.data.patchValue({ + type: type, + hours: minutes ? Math.floor(minutes / 60) : DEFAULT_HOURS, + minutes: minutes ? minutes % 60 : DEFAULT_MINUTES, + action: action, + }); + } + + protected override buildRequestData() { + this.data.markAllAsTouched(); + this.data.updateValueAndValidity(); + if (this.data.invalid) { + if (this.data.controls.type.hasError("required")) { + throw new Error(this.i18nService.t("maximumAllowedTimeoutRequired")); + } + throw new Error(this.i18nService.t("sessionTimeoutPolicyInvalidTime")); + } + + let minutes = this.data.value.hours! * 60 + this.data.value.minutes!; + + const type = this.data.value.type; + if (type === "custom") { + if (minutes <= 0) { + throw new Error(this.i18nService.t("sessionTimeoutPolicyInvalidTime")); + } + } else { + // For backwards compatibility, we set minutes to 8 hours, so older client's vault timeout will not be broken + minutes = DEFAULT_HOURS * 60 + DEFAULT_MINUTES; + } + + return { + type, + minutes, + action: this.data.value.action, + }; + } + + private async confirmTypeChange(newType: SessionTimeoutType): Promise { + if (newType === "never") { + const dialogRef = SessionTimeoutConfirmationNeverComponent.open(this.dialogService); + return !!(await firstValueFrom(dialogRef.closed)); + } else if (newType === "onSystemLock") { + return await this.dialogService.openSimpleDialog({ + type: "info", + title: { key: "sessionTimeoutConfirmationOnSystemLockTitle" }, + content: { key: "sessionTimeoutConfirmationOnSystemLockDescription" }, + acceptButtonText: { key: "continue" }, + cancelButtonText: { key: "cancel" }, + }); + } + + return true; + } + + private updateFormControls(type: SessionTimeoutType) { + const hoursControl = this.data.controls.hours; + const minutesControl = this.data.controls.minutes; + if (type === "custom") { + hoursControl.enable(); + minutesControl.enable(); + } else { + hoursControl.disable(); + minutesControl.disable(); + } + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/guards/project-access.guard.spec.ts b/bitwarden_license/bit-web/src/app/secrets-manager/guards/project-access.guard.spec.ts index f442c85f46d..79c022e8fd2 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/guards/project-access.guard.spec.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/guards/project-access.guard.spec.ts @@ -20,12 +20,16 @@ import { ProjectService } from "../projects/project.service"; import { projectAccessGuard } from "./project-access.guard"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "", standalone: false, }) export class GuardedRouteTestComponent {} +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.spec.ts b/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.spec.ts index 978cfeb1aa4..0e8c46c8864 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.spec.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.spec.ts @@ -21,6 +21,8 @@ import { IntegrationGridComponent } from "../../dirt/organization-integrations/i import { IntegrationsComponent } from "./integrations.component"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-header", template: "
", @@ -28,6 +30,8 @@ import { IntegrationsComponent } from "./integrations.component"; }) class MockHeaderComponent {} +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-new-menu", template: "
", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.ts index b2279775191..37c7a93d27f 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.ts @@ -3,6 +3,8 @@ import { Component } from "@angular/core"; import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; import { IntegrationType } from "@bitwarden/common/enums"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-integrations", templateUrl: "./integrations.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.component.ts index b50e586c337..00a4c6cc4d4 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.component.ts @@ -1,5 +1,7 @@ import { Component, OnInit } from "@angular/core"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-layout", templateUrl: "./layout.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.ts index a714bc0d543..be9124ee3e1 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.ts @@ -31,6 +31,8 @@ import { ServiceAccountService } from "../service-accounts/service-account.servi import { SecretsManagerPortingApiService } from "../settings/services/sm-porting-api.service"; import { CountService } from "../shared/counts/count.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-navigation", templateUrl: "./navigation.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts index e301c0462c3..12a5432c4b8 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts @@ -75,6 +75,8 @@ type OrganizationTasks = { createServiceAccount: boolean; }; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-overview", templateUrl: "./overview.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/section.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/section.component.ts index 6b71c81f09e..0691ed9dd73 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/section.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/section.component.ts @@ -1,11 +1,15 @@ import { Component, Input } from "@angular/core"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-section", templateUrl: "./section.component.html", standalone: false, }) export class SectionComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() open = true; /** diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-delete-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-delete-dialog.component.ts index 8cdb1bb4d69..3ddf3233b38 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-delete-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-delete-dialog.component.ts @@ -25,6 +25,8 @@ export interface ProjectDeleteOperation { projects: ProjectListView[]; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./project-delete-dialog.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts index 819f2107fcf..2f6b2229d75 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts @@ -25,6 +25,8 @@ export interface ProjectOperation { projectId?: string; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./project-dialog.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-people.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-people.component.ts index ec7397a22a8..49b016e921c 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-people.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-people.component.ts @@ -24,6 +24,8 @@ import { import { ApItemEnum } from "../../shared/access-policies/access-policy-selector/models/enums/ap-item.enum"; import { AccessPolicyService } from "../../shared/access-policies/access-policy.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-project-people", templateUrl: "./project-people.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts index 5c83f784431..7112a28010f 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts @@ -41,6 +41,8 @@ import { import { SecretService } from "../../secrets/secret.service"; import { SecretsListComponent } from "../../shared/secrets-list.component"; import { ProjectService } from "../project.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-project-secrets", templateUrl: "./project-secrets.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-service-accounts.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-service-accounts.component.ts index fc3a489bce9..e2fd8556621 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-service-accounts.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-service-accounts.component.ts @@ -22,6 +22,8 @@ import { } from "../../shared/access-policies/access-policy-selector/models/ap-item-view.type"; import { AccessPolicyService } from "../../shared/access-policies/access-policy.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-project-service-accounts", templateUrl: "./project-service-accounts.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts index c79ebd733c0..7c1812e3f26 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts @@ -34,6 +34,8 @@ import { } from "../dialog/project-dialog.component"; import { ProjectService } from "../project.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-project", templateUrl: "./project.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.ts index 81a568f0c65..10e75cfb75a 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.ts @@ -40,6 +40,8 @@ import { } from "../dialog/project-dialog.component"; import { ProjectService } from "../project.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-projects", templateUrl: "./projects.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-delete.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-delete.component.ts index 6340cc42f3b..344a20f02c2 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-delete.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-delete.component.ts @@ -18,6 +18,8 @@ export interface SecretDeleteOperation { secrets: SecretListView[]; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./secret-delete.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts index 9172d44965d..6376b58423d 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts @@ -67,6 +67,8 @@ export interface SecretOperation { organizationEnabled: boolean; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./secret-dialog.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-view-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-view-dialog.component.ts index b719014a382..ace8db4e6ba 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-view-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-view-dialog.component.ts @@ -10,6 +10,8 @@ export interface SecretViewDialogParams { secretId: string; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./secret-view-dialog.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts index ca093f449c9..46cccb1d95d 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts @@ -34,6 +34,8 @@ import { } from "./dialog/secret-view-dialog.component"; import { SecretService } from "./secret.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-secrets", templateUrl: "./secrets.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.ts index a714729d96f..7a8c0b37408 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.ts @@ -5,12 +5,16 @@ import { Component, EventEmitter, Input, Output } from "@angular/core"; import { AccessTokenView } from "../models/view/access-token.view"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-access-list", templateUrl: "./access-list.component.html", standalone: false, }) export class AccessListComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() get tokens(): AccessTokenView[] { return this._tokens; @@ -21,7 +25,11 @@ export class AccessListComponent { } private _tokens: AccessTokenView[]; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() newAccessTokenEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() revokeAccessTokensEvent = new EventEmitter(); protected selection = new SelectionModel(true, []); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-tokens.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-tokens.component.ts index b9643ce8fd8..4e9069cd6cb 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-tokens.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-tokens.component.ts @@ -24,6 +24,8 @@ import { ServiceAccountService } from "../service-account.service"; import { AccessService } from "./access.service"; import { AccessTokenCreateDialogComponent } from "./dialogs/access-token-create-dialog.component"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-access-tokens", templateUrl: "./access-tokens.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-create-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-create-dialog.component.ts index dfbe0a1511d..3aca93572ef 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-create-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-create-dialog.component.ts @@ -15,6 +15,8 @@ export interface AccessTokenOperation { serviceAccountView: ServiceAccountView; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./access-token-create-dialog.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-dialog.component.ts index 0259b8d6e90..cf5118c5062 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-dialog.component.ts @@ -12,6 +12,8 @@ export interface AccessTokenDetails { accessToken: string; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./access-token-dialog.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/expiration-options.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/expiration-options.component.ts index 891501874ff..a0db42d03b0 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/expiration-options.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/expiration-options.component.ts @@ -18,6 +18,8 @@ import { Subject, takeUntil } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-expiration-options", templateUrl: "./expiration-options.component.html", @@ -40,8 +42,12 @@ export class ExpirationOptionsComponent { private destroy$ = new Subject(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() expirationDayOptions: number[]; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() set touched(val: boolean) { if (val) { this.form.markAllAsTouched(); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/config/config.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/config/config.component.ts index f85cde90306..18ef397c6ae 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/config/config.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/config/config.component.ts @@ -24,6 +24,8 @@ class ServiceAccountConfig { projects: ProjectListView[]; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-service-account-config", templateUrl: "./config.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-delete-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-delete-dialog.component.ts index 5edc57d8c74..638ee6862a3 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-delete-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-delete-dialog.component.ts @@ -25,6 +25,8 @@ export interface ServiceAccountDeleteOperation { serviceAccounts: ServiceAccountView[]; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./service-account-delete-dialog.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts index 250e0870ecf..5c6072807a6 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts @@ -24,6 +24,8 @@ export interface ServiceAccountOperation { organizationEnabled: boolean; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./service-account-dialog.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.ts index 2e364df1423..5968933064d 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.ts @@ -17,6 +17,8 @@ import { EventExportService } from "@bitwarden/web-vault/app/tools/event-export" import { ServiceAccountEventLogApiService } from "./service-account-event-log-api.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-service-accounts-events", templateUrl: "./service-accounts-events.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/guards/service-account-access.guard.spec.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/guards/service-account-access.guard.spec.ts index e0bcad8d6e9..e7b258ed1c2 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/guards/service-account-access.guard.spec.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/guards/service-account-access.guard.spec.ts @@ -20,12 +20,16 @@ import { ServiceAccountService } from "../service-account.service"; import { serviceAccountAccessGuard } from "./service-account-access.guard"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "", standalone: false, }) export class GuardedRouteTestComponent {} +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/people/service-account-people.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/people/service-account-people.component.ts index 4449757167d..42ab2ec613b 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/people/service-account-people.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/people/service-account-people.component.ts @@ -25,6 +25,8 @@ import { ApItemEnum } from "../../shared/access-policies/access-policy-selector/ import { ApPermissionEnum } from "../../shared/access-policies/access-policy-selector/models/enums/ap-permission.enum"; import { AccessPolicyService } from "../../shared/access-policies/access-policy.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-service-account-people", templateUrl: "./service-account-people.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.ts index af334b22c63..6d4490bad3c 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.ts @@ -22,6 +22,8 @@ import { } from "../../shared/access-policies/access-policy-selector/models/ap-item-view.type"; import { AccessPolicyService } from "../../shared/access-policies/access-policy.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-service-account-projects", templateUrl: "./service-account-projects.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.ts index 5eb074e3e99..285f03acb01 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.ts @@ -15,6 +15,8 @@ import { AccessService } from "./access/access.service"; import { AccessTokenCreateDialogComponent } from "./access/dialogs/access-token-create-dialog.component"; import { ServiceAccountService } from "./service-account.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-service-account", templateUrl: "./service-account.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.ts index 21f11d6bfed..4febda9ea28 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.ts @@ -21,6 +21,8 @@ import { ServiceAccountView, } from "../models/view/service-account.view"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-service-accounts-list", templateUrl: "./service-accounts-list.component.html", @@ -29,6 +31,8 @@ import { export class ServiceAccountsListComponent implements OnDestroy, OnInit { protected dataSource = new TableDataSource(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() get serviceAccounts(): ServiceAccountSecretsDetailsView[] { return this._serviceAccounts; @@ -40,15 +44,25 @@ export class ServiceAccountsListComponent implements OnDestroy, OnInit { } private _serviceAccounts: ServiceAccountSecretsDetailsView[]; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() set search(search: string) { this.selection.clear(); this.dataSource.filter = search; } + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() newServiceAccountEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() deleteServiceAccountsEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onServiceAccountCheckedEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() editServiceAccountEvent = new EventEmitter(); private destroy$: Subject = new Subject(); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.ts index 345fff03876..5d6b4fd49de 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.ts @@ -30,6 +30,8 @@ import { } from "./dialog/service-account-dialog.component"; import { ServiceAccountService } from "./service-account.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-service-accounts", templateUrl: "./service-accounts.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/settings/dialog/sm-import-error-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/settings/dialog/sm-import-error-dialog.component.ts index 0bed0355a8c..85e054d998b 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/settings/dialog/sm-import-error-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/settings/dialog/sm-import-error-dialog.component.ts @@ -10,6 +10,8 @@ export interface SecretsManagerImportErrorDialogOperation { error: SecretsManagerImportError; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./sm-import-error-dialog.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.ts index c2b726803c5..e2b66d9ffa6 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.ts @@ -26,6 +26,8 @@ type ExportFormat = { fileExtension: string; }; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-export", templateUrl: "./sm-export.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-import.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-import.component.ts index 65075d12bf6..c2ffe5536b8 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-import.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-import.component.ts @@ -18,6 +18,8 @@ import { import { SecretsManagerImportError } from "../models/error/sm-import-error"; import { SecretsManagerPortingApiService } from "../services/sm-porting-api.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-import", templateUrl: "./sm-import.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.component.ts index fba3ff03ee0..2bb4d6cb37f 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.component.ts @@ -20,6 +20,8 @@ import { ApItemViewType } from "./models/ap-item-view.type"; import { ApItemEnumUtil, ApItemEnum } from "./models/enums/ap-item.enum"; import { ApPermissionEnum } from "./models/enums/ap-permission.enum"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-access-policy-selector", templateUrl: "access-policy-selector.component.html", @@ -108,23 +110,43 @@ export class AccessPolicySelectorComponent implements ControlValueAccessor, OnIn disabled: boolean; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() loading: boolean; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() addButtonMode: boolean; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() label: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() hint: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() columnTitle: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() emptyMessage: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() permissionList = [ { perm: ApPermissionEnum.CanRead, labelId: "canRead" }, { perm: ApPermissionEnum.CanReadWrite, labelId: "canReadWrite" }, ]; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() initialPermission = ApPermissionEnum.CanRead; // Pass in a static permission that wil be the only option for a given selector instance. // Will ignore permissionList and initialPermission. + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() staticPermission: ApPermissionEnum; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() get items(): ApItemViewType[] { return this.selectionList.allItems; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/dialogs/bulk-confirmation-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/dialogs/bulk-confirmation-dialog.component.ts index 9d2a3715e16..0f0991d52a9 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/dialogs/bulk-confirmation-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/dialogs/bulk-confirmation-dialog.component.ts @@ -22,6 +22,8 @@ export enum BulkConfirmationResult { Cancel, } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-bulk-confirmation-dialog", templateUrl: "./bulk-confirmation-dialog.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/dialogs/bulk-status-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/dialogs/bulk-status-dialog.component.ts index fc7890f1654..8e27b551e55 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/dialogs/bulk-status-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/dialogs/bulk-status-dialog.component.ts @@ -18,6 +18,8 @@ export class BulkOperationStatus { errorMessage?: string; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./bulk-status-dialog.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/new-menu.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/new-menu.component.ts index 18823130d22..6c3d4228c06 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/new-menu.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/new-menu.component.ts @@ -26,6 +26,8 @@ import { ServiceAccountOperation, } from "../service-accounts/dialog/service-account-dialog.component"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-new-menu", templateUrl: "./new-menu.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts index 6777df7ef7a..f2e0d48fe1d 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts @@ -10,6 +10,8 @@ import { import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./org-suspended.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/projects-list.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/projects-list.component.ts index 31114bcd1c4..5d3c806f386 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/projects-list.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/projects-list.component.ts @@ -20,12 +20,16 @@ import { openEntityEventsDialog } from "@bitwarden/web-vault/app/admin-console/o import { ProjectListView } from "../models/view/project-list.view"; import { ProjectView } from "../models/view/project.view"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-projects-list", templateUrl: "./projects-list.component.html", standalone: false, }) export class ProjectsListComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() get projects(): ProjectListView[] { return this._projects; @@ -40,17 +44,29 @@ export class ProjectsListComponent implements OnInit { protected isAdmin$: Observable; private destroy$: Subject = new Subject(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() showMenus?: boolean = true; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() set search(search: string) { this.selection.clear(); this.dataSource.filter = search; } + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() editProjectEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() deleteProjectEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() newProjectEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() copiedProjectUUIdEvent = new EventEmitter(); selection = new SelectionModel(true, []); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.ts index 4ef7dbf22e7..05e38baff69 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.ts @@ -21,6 +21,8 @@ import { SecretListView } from "../models/view/secret-list.view"; import { SecretView } from "../models/view/secret.view"; import { SecretService } from "../secrets/secret.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-secrets-list", templateUrl: "./secrets-list.component.html", @@ -29,6 +31,8 @@ import { SecretService } from "../secrets/secret.service"; export class SecretsListComponent implements OnDestroy, OnInit { protected dataSource = new TableDataSource(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() get secrets(): SecretListView[] { return this._secrets; @@ -40,22 +44,44 @@ export class SecretsListComponent implements OnDestroy, OnInit { } private _secrets: SecretListView[]; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() set search(search: string) { this.selection.clear(); this.dataSource.filter = search; } + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() trash: boolean; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() editSecretEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() viewSecretEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() copySecretNameEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() copySecretValueEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() copySecretUuidEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onSecretCheckedEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() deleteSecretsEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() newSecretEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() restoreSecretsEvent = new EventEmitter(); private destroy$: Subject = new Subject(); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/trash/dialog/secret-hard-delete.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/trash/dialog/secret-hard-delete.component.ts index 29f9a85250c..521550185f1 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/trash/dialog/secret-hard-delete.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/trash/dialog/secret-hard-delete.component.ts @@ -13,6 +13,8 @@ export interface SecretHardDeleteOperation { organizationId: string; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./secret-hard-delete.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/trash/dialog/secret-restore.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/trash/dialog/secret-restore.component.ts index 712757445be..034b6f8de00 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/trash/dialog/secret-restore.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/trash/dialog/secret-restore.component.ts @@ -13,6 +13,8 @@ export interface SecretRestoreOperation { organizationId: string; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./secret-restore.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/trash/trash.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/trash/trash.component.ts index 4392ae8b1bb..b4da7769127 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/trash/trash.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/trash/trash.component.ts @@ -21,6 +21,8 @@ import { SecretRestoreOperation, } from "./dialog/secret-restore.component"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-trash", templateUrl: "./trash.component.html", diff --git a/eslint.config.mjs b/eslint.config.mjs index d8b2094c37c..656972d2421 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -72,9 +72,9 @@ export default tseslint.config( "@angular-eslint/no-output-on-prefix": 0, "@angular-eslint/no-output-rename": 0, "@angular-eslint/no-outputs-metadata-property": 0, - "@angular-eslint/prefer-on-push-component-change-detection": "warn", - "@angular-eslint/prefer-output-emitter-ref": "warn", - "@angular-eslint/prefer-signals": "warn", + "@angular-eslint/prefer-on-push-component-change-detection": "error", + "@angular-eslint/prefer-output-emitter-ref": "error", + "@angular-eslint/prefer-signals": "error", "@angular-eslint/prefer-standalone": 0, "@angular-eslint/use-lifecycle-interface": "error", "@angular-eslint/use-pipe-transform-interface": 0, diff --git a/libs/angular/src/components/callout.component.ts b/libs/angular/src/components/callout.component.ts index 215de49f676..9630b761076 100644 --- a/libs/angular/src/components/callout.component.ts +++ b/libs/angular/src/components/callout.component.ts @@ -9,17 +9,31 @@ import { CalloutTypes } from "@bitwarden/components"; /** * @deprecated use the CL's `CalloutComponent` instead */ +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-callout", templateUrl: "callout.component.html", standalone: false, }) export class DeprecatedCalloutComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() type: CalloutTypes = "info"; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() icon: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() title: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() enforcedPolicyOptions: MasterPasswordPolicyOptions; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() enforcedPolicyMessage: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() useAlertRole = false; calloutStyle: string; diff --git a/libs/angular/src/components/modal/dynamic-modal.component.ts b/libs/angular/src/components/modal/dynamic-modal.component.ts index 77491193916..ea40dd1a877 100644 --- a/libs/angular/src/components/modal/dynamic-modal.component.ts +++ b/libs/angular/src/components/modal/dynamic-modal.component.ts @@ -15,6 +15,8 @@ import { import { ModalRef } from "./modal.ref"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-modal", template: "", @@ -23,6 +25,8 @@ import { ModalRef } from "./modal.ref"; export class DynamicModalComponent implements AfterViewInit, OnDestroy { componentRef: ComponentRef; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("modalContent", { read: ViewContainerRef, static: true }) modalContentRef: ViewContainerRef; diff --git a/libs/angular/src/directives/api-action.directive.ts b/libs/angular/src/directives/api-action.directive.ts index 85ba8a7489c..6873e448589 100644 --- a/libs/angular/src/directives/api-action.directive.ts +++ b/libs/angular/src/directives/api-action.directive.ts @@ -18,6 +18,8 @@ import { ValidationService } from "@bitwarden/common/platform/abstractions/valid standalone: false, }) export class ApiActionDirective implements OnChanges { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() appApiAction: Promise; constructor( diff --git a/libs/angular/src/directives/copy-text.directive.ts b/libs/angular/src/directives/copy-text.directive.ts index 0f9018e19ad..aefb26ef07e 100644 --- a/libs/angular/src/directives/copy-text.directive.ts +++ b/libs/angular/src/directives/copy-text.directive.ts @@ -15,6 +15,8 @@ export class CopyTextDirective { private platformUtilsService: PlatformUtilsService, ) {} + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input("appCopyText") copyText: string; @HostListener("copy") onCopy() { diff --git a/libs/angular/src/directives/fallback-src.directive.ts b/libs/angular/src/directives/fallback-src.directive.ts index f1225245912..b63dc8671cf 100644 --- a/libs/angular/src/directives/fallback-src.directive.ts +++ b/libs/angular/src/directives/fallback-src.directive.ts @@ -7,6 +7,8 @@ import { Directive, ElementRef, HostListener, Input } from "@angular/core"; standalone: false, }) export class FallbackSrcDirective { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input("appFallbackSrc") appFallbackSrc: string; /** Only try setting the fallback once. This prevents an infinite loop if the fallback itself is missing. */ diff --git a/libs/angular/src/directives/if-feature.directive.spec.ts b/libs/angular/src/directives/if-feature.directive.spec.ts index d7c49994045..357209b0e64 100644 --- a/libs/angular/src/directives/if-feature.directive.spec.ts +++ b/libs/angular/src/directives/if-feature.directive.spec.ts @@ -13,6 +13,8 @@ const testBooleanFeature: FeatureFlag = "boolean-feature" as FeatureFlag; const testStringFeature: FeatureFlag = "string-feature" as FeatureFlag; const testStringFeatureValue = "test-value"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: `
diff --git a/libs/angular/src/directives/if-feature.directive.ts b/libs/angular/src/directives/if-feature.directive.ts index aa10c9e8081..28cf1d5c35f 100644 --- a/libs/angular/src/directives/if-feature.directive.ts +++ b/libs/angular/src/directives/if-feature.directive.ts @@ -20,12 +20,16 @@ export class IfFeatureDirective implements OnInit { /** * The feature flag to check. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() appIfFeature: FeatureFlag; /** * Optional value to compare against the value of the feature flag in the config service. * @default true */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() appIfFeatureValue: AllowedFeatureFlagTypes = true; private hasView = false; diff --git a/libs/angular/src/directives/input-verbatim.directive.ts b/libs/angular/src/directives/input-verbatim.directive.ts index 7bd18b12659..1240523d2bf 100644 --- a/libs/angular/src/directives/input-verbatim.directive.ts +++ b/libs/angular/src/directives/input-verbatim.directive.ts @@ -7,6 +7,8 @@ import { Directive, ElementRef, Input, OnInit, Renderer2 } from "@angular/core"; standalone: false, }) export class InputVerbatimDirective implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() set appInputVerbatim(condition: boolean | string) { this.disableComplete = condition === "" || condition === true; } diff --git a/libs/angular/src/directives/launch-click.directive.ts b/libs/angular/src/directives/launch-click.directive.ts index b270dbba5e3..ce44648dc37 100644 --- a/libs/angular/src/directives/launch-click.directive.ts +++ b/libs/angular/src/directives/launch-click.directive.ts @@ -10,6 +10,8 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; export class LaunchClickDirective { constructor(private platformUtilsService: PlatformUtilsService) {} + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input("appLaunchClick") uriToLaunch = ""; @HostListener("click") onClick() { diff --git a/libs/angular/src/directives/text-drag.directive.ts b/libs/angular/src/directives/text-drag.directive.ts index 6202c552a87..aade2798dc7 100644 --- a/libs/angular/src/directives/text-drag.directive.ts +++ b/libs/angular/src/directives/text-drag.directive.ts @@ -8,6 +8,8 @@ import { Directive, HostListener, Input } from "@angular/core"; }, }) export class TextDragDirective { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ alias: "appTextDrag", required: true, diff --git a/libs/angular/src/directives/true-false-value.directive.ts b/libs/angular/src/directives/true-false-value.directive.ts index 5d25ac2a385..78c1b4647c6 100644 --- a/libs/angular/src/directives/true-false-value.directive.ts +++ b/libs/angular/src/directives/true-false-value.directive.ts @@ -14,7 +14,11 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; standalone: false, }) export class TrueFalseValueDirective implements ControlValueAccessor { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() trueValue: boolean | string = true; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() falseValue: boolean | string = false; constructor( diff --git a/libs/auth/src/angular/input-password/input-password.component.ts b/libs/auth/src/angular/input-password/input-password.component.ts index 019a9e3975e..62294f037a0 100644 --- a/libs/auth/src/angular/input-password/input-password.component.ts +++ b/libs/auth/src/angular/input-password/input-password.component.ts @@ -564,7 +564,7 @@ export class InputPasswordComponent implements OnInit { } } else if (passwordIsWeak) { const userAcceptedDialog = await this.dialogService.openSimpleDialog({ - title: { key: "weakMasterPasswordDesc" }, + title: { key: "weakMasterPassword" }, content: { key: "weakMasterPasswordDesc" }, type: "warning", }); diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index d9cd1dbfab3..bfb40aff106 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -23,7 +23,6 @@ export enum FeatureFlag { /* Billing */ TrialPaymentOptional = "PM-8163-trial-payment", - PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships", PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover", PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings", PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button", @@ -56,6 +55,7 @@ export enum FeatureFlag { PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view", PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption", CipherKeyEncryption = "cipher-key-encryption", + AutofillConfirmation = "pm-25083-autofill-confirm-from-search", /* Platform */ IpcChannelFramework = "ipc-channel-framework", @@ -103,13 +103,13 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE, [FeatureFlag.PM22134SdkCipherListView]: FALSE, [FeatureFlag.PM22136_SdkCipherEncryption]: FALSE, + [FeatureFlag.AutofillConfirmation]: FALSE, /* Auth */ [FeatureFlag.PM22110_DisableAlternateLoginMethods]: FALSE, /* Billing */ [FeatureFlag.TrialPaymentOptional]: FALSE, - [FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE, [FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE, [FeatureFlag.PM22415_TaxIDWarnings]: FALSE, [FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton]: FALSE, diff --git a/libs/common/src/vault/services/restricted-item-types.service.spec.ts b/libs/common/src/vault/services/restricted-item-types.service.spec.ts index 3ae68d47c5c..c16a91d0884 100644 --- a/libs/common/src/vault/services/restricted-item-types.service.spec.ts +++ b/libs/common/src/vault/services/restricted-item-types.service.spec.ts @@ -12,6 +12,8 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherLike } from "../types/cipher-like"; + import { RestrictedItemTypesService, RestrictedCipherType } from "./restricted-item-types.service"; describe("RestrictedItemTypesService", () => { @@ -130,4 +132,170 @@ describe("RestrictedItemTypesService", () => { { cipherType: CipherType.Identity, allowViewOrgIds: ["org1"] }, ]); }); + + describe("isCipherRestricted", () => { + it("returns false when cipher type is not in restricted types", () => { + const cipher: CipherLike = { + type: CipherType.Login, + organizationId: "Pete the Cat", + } as CipherLike; + const restrictedTypes: RestrictedCipherType[] = [ + { cipherType: CipherType.Card, allowViewOrgIds: [] }, + ]; + + const result = service.isCipherRestricted(cipher, restrictedTypes); + + expect(result).toBe(false); + }); + + it("returns false when restricted types array is empty", () => { + const cipher: CipherLike = { type: CipherType.Card, organizationId: "org1" } as CipherLike; + const restrictedTypes: RestrictedCipherType[] = []; + + const result = service.isCipherRestricted(cipher, restrictedTypes); + + expect(result).toBe(false); + }); + + it("returns false when cipher type does not match any restricted types", () => { + const cipher: CipherLike = { + type: CipherType.SecureNote, + organizationId: "org1", + } as CipherLike; + const restrictedTypes: RestrictedCipherType[] = [ + { cipherType: CipherType.Card, allowViewOrgIds: [] }, + { cipherType: CipherType.Login, allowViewOrgIds: [] }, + { cipherType: CipherType.Identity, allowViewOrgIds: [] }, + ]; + + const result = service.isCipherRestricted(cipher, restrictedTypes); + + expect(result).toBe(false); + }); + + it("returns true for personal cipher when type is restricted", () => { + const cipher: CipherLike = { type: CipherType.Card, organizationId: null } as CipherLike; + const restrictedTypes: RestrictedCipherType[] = [ + { cipherType: CipherType.Card, allowViewOrgIds: ["org1"] }, + ]; + + const result = service.isCipherRestricted(cipher, restrictedTypes); + + expect(result).toBe(true); + }); + + it("returns true for personal cipher with undefined organizationId when type is restricted", () => { + const cipher: CipherLike = { + type: CipherType.Login, + organizationId: undefined, + } as CipherLike; + const restrictedTypes: RestrictedCipherType[] = [ + { cipherType: CipherType.Login, allowViewOrgIds: ["org1", "org2"] }, + ]; + + const result = service.isCipherRestricted(cipher, restrictedTypes); + + expect(result).toBe(true); + }); + + it("returns true for personal cipher regardless of allowViewOrgIds content", () => { + const cipher: CipherLike = { type: CipherType.Identity, organizationId: null } as CipherLike; + const restrictedTypes: RestrictedCipherType[] = [ + { cipherType: CipherType.Identity, allowViewOrgIds: [] }, + ]; + + const result = service.isCipherRestricted(cipher, restrictedTypes); + + expect(result).toBe(true); + }); + + it("returns false when organization is in allowViewOrgIds", () => { + const cipher: CipherLike = { type: CipherType.Card, organizationId: "org1" } as CipherLike; + const restrictedTypes: RestrictedCipherType[] = [ + { cipherType: CipherType.Card, allowViewOrgIds: ["org1"] }, + ]; + + const result = service.isCipherRestricted(cipher, restrictedTypes); + + expect(result).toBe(false); + }); + + it("returns false when organization is among multiple allowViewOrgIds", () => { + const cipher: CipherLike = { type: CipherType.Login, organizationId: "org2" } as CipherLike; + const restrictedTypes: RestrictedCipherType[] = [ + { cipherType: CipherType.Login, allowViewOrgIds: ["org1", "org2", "org3"] }, + ]; + + const result = service.isCipherRestricted(cipher, restrictedTypes); + + expect(result).toBe(false); + }); + + it("returns false when type is restricted globally but cipher org allows it", () => { + const cipher: CipherLike = { type: CipherType.Card, organizationId: "org2" } as CipherLike; + const restrictedTypes: RestrictedCipherType[] = [ + { cipherType: CipherType.Card, allowViewOrgIds: ["org2"] }, + ]; + + const result = service.isCipherRestricted(cipher, restrictedTypes); + + expect(result).toBe(false); + }); + + it("returns true when organization is not in allowViewOrgIds", () => { + const cipher: CipherLike = { type: CipherType.Card, organizationId: "org3" } as CipherLike; + const restrictedTypes: RestrictedCipherType[] = [ + { cipherType: CipherType.Card, allowViewOrgIds: ["org1", "org2"] }, + ]; + + const result = service.isCipherRestricted(cipher, restrictedTypes); + + expect(result).toBe(true); + }); + + it("returns true when allowViewOrgIds is empty for org cipher", () => { + const cipher: CipherLike = { type: CipherType.Login, organizationId: "org1" } as CipherLike; + const restrictedTypes: RestrictedCipherType[] = [ + { cipherType: CipherType.Login, allowViewOrgIds: [] }, + ]; + + const result = service.isCipherRestricted(cipher, restrictedTypes); + + expect(result).toBe(true); + }); + + it("returns true when cipher org differs from all allowViewOrgIds", () => { + const cipher: CipherLike = { + type: CipherType.Identity, + organizationId: "org5", + } as CipherLike; + const restrictedTypes: RestrictedCipherType[] = [ + { cipherType: CipherType.Identity, allowViewOrgIds: ["org1", "org2", "org3", "org4"] }, + ]; + + const result = service.isCipherRestricted(cipher, restrictedTypes); + + expect(result).toBe(true); + }); + }); + + describe("isCipherRestricted$", () => { + it("returns true when cipher is restricted by policy", async () => { + policyService.policiesByType$.mockReturnValue(of([policyOrg1])); + const cipher: CipherLike = { type: CipherType.Card, organizationId: null } as CipherLike; + + const result = await firstValueFrom(service.isCipherRestricted$(cipher)); + + expect(result).toBe(true); + }); + + it("returns false when cipher is not restricted", async () => { + policyService.policiesByType$.mockReturnValue(of([policyOrg1])); + const cipher: CipherLike = { type: CipherType.Login, organizationId: "org2" } as CipherLike; + + const result = await firstValueFrom(service.isCipherRestricted$(cipher)); + + expect(result).toBe(false); + }); + }); }); diff --git a/libs/components/src/button/button.component.ts b/libs/components/src/button/button.component.ts index 350d493f832..6ef5309b018 100644 --- a/libs/components/src/button/button.component.ts +++ b/libs/components/src/button/button.component.ts @@ -92,7 +92,6 @@ export class ButtonComponent implements ButtonLikeAbstraction { "hover:!tw-text-muted", "aria-disabled:tw-cursor-not-allowed", "hover:tw-no-underline", - "aria-disabled:tw-pointer-events-none", ] : [], ) diff --git a/libs/components/src/card/base-card/base-card.component.ts b/libs/components/src/card/base-card/base-card.component.ts index 44f82a32c47..8c4dd80f2d1 100644 --- a/libs/components/src/card/base-card/base-card.component.ts +++ b/libs/components/src/card/base-card/base-card.component.ts @@ -6,6 +6,8 @@ import { BaseCardDirective } from "./base-card.directive"; * The base card component is a container that applies our standard card border and box-shadow. * In most cases using our `` component should suffice. */ +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "bit-base-card", template: ``, diff --git a/libs/components/src/card/card-content.component.ts b/libs/components/src/card/card-content.component.ts index 60be20e78f0..650a2665475 100644 --- a/libs/components/src/card/card-content.component.ts +++ b/libs/components/src/card/card-content.component.ts @@ -1,5 +1,7 @@ import { Component } from "@angular/core"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "bit-card-content", template: `
`, diff --git a/libs/components/src/icon-button/icon-button.component.ts b/libs/components/src/icon-button/icon-button.component.ts index f1edee7c089..9887c0bde8b 100644 --- a/libs/components/src/icon-button/icon-button.component.ts +++ b/libs/components/src/icon-button/icon-button.component.ts @@ -17,6 +17,7 @@ import { setA11yTitleAndAriaLabel } from "../a11y/set-a11y-title-and-aria-label" import { ButtonLikeAbstraction } from "../shared/button-like.abstraction"; import { FocusableElement } from "../shared/focusable-element"; import { SpinnerComponent } from "../spinner"; +import { TooltipDirective } from "../tooltip"; import { ariaDisableElement } from "../utils"; export type IconButtonType = "primary" | "danger" | "contrast" | "main" | "muted" | "nav-contrast"; @@ -100,7 +101,10 @@ const sizes: Record = { */ "[attr.bitIconButton]": "icon()", }, - hostDirectives: [AriaDisableDirective], + hostDirectives: [ + AriaDisableDirective, + { directive: TooltipDirective, inputs: ["tooltipPosition"] }, + ], }) export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableElement { readonly icon = model.required({ alias: "bitIconButton" }); @@ -109,6 +113,9 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE readonly size = model("default"); + private elementRef = inject(ElementRef); + private tooltip = inject(TooltipDirective, { host: true, optional: true }); + /** * label input will be used to set the `aria-label` attributes on the button. * This is for accessibility purposes, as it provides a text alternative for the icon button. @@ -186,8 +193,6 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE return this.elementRef.nativeElement; } - private elementRef = inject(ElementRef); - constructor() { const element = this.elementRef.nativeElement; @@ -198,9 +203,15 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE effect(() => { setA11yTitleAndAriaLabel({ element: this.elementRef.nativeElement, - title: originalTitle ?? this.label(), + title: undefined, label: this.label(), }); + + const tooltipContent: string = originalTitle || this.label(); + + if (tooltipContent) { + this.tooltip?.tooltipContent.set(tooltipContent); + } }); } } diff --git a/libs/components/src/navigation/nav-group.component.html b/libs/components/src/navigation/nav-group.component.html index 195569292f6..bcf6ae2b5b7 100644 --- a/libs/components/src/navigation/nav-group.component.html +++ b/libs/components/src/navigation/nav-group.component.html @@ -8,7 +8,8 @@ [routerLinkActiveOptions]="routerLinkActiveOptions()" (mainContentClicked)="handleMainContentClicked()" [ariaLabel]="ariaLabel()" - [hideActiveStyles]="parentHideActiveStyles" + [hideActiveStyles]="parentHideActiveStyles()" + [ariaCurrentWhenActive]="ariaCurrent()" >