diff --git a/CLAUDE.md b/.claude/CLAUDE.md similarity index 100% rename from CLAUDE.md rename to .claude/CLAUDE.md diff --git a/.claude/prompts/review-code.md b/.claude/prompts/review-code.md new file mode 100644 index 00000000000..4e5f40b2743 --- /dev/null +++ b/.claude/prompts/review-code.md @@ -0,0 +1,25 @@ +Please review this pull request with a focus on: + +- Code quality and best practices +- Potential bugs or issues +- Security implications +- Performance considerations + +Note: The PR branch is already checked out in the current working directory. + +Provide a comprehensive review including: + +- Summary of changes since last review +- Critical issues found (be thorough) +- Suggested improvements (be thorough) +- Good practices observed (be concise - list only the most notable items without elaboration) +- Action items for the author +- Leverage collapsible
sections where appropriate for lengthy explanations or code snippets to enhance human readability + +When reviewing subsequent commits: + +- Track status of previously identified issues (fixed/unfixed/reopened) +- Identify NEW problems introduced since last review +- Note if fixes introduced new issues + +IMPORTANT: Be comprehensive about issues and improvements. For good practices, be brief - just note what was done well without explaining why or praising excessively. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f03cf3ee2a8..676c4b4657b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -30,7 +30,7 @@ libs/common/src/auth @bitwarden/team-auth-dev apps/browser/src/tools @bitwarden/team-tools-dev apps/cli/src/tools @bitwarden/team-tools-dev apps/desktop/src/app/tools @bitwarden/team-tools-dev -apps/desktop/desktop_native/bitwarden_chromium_importer @bitwarden/team-tools-dev +apps/desktop/desktop_native/chromium_importer @bitwarden/team-tools-dev apps/web/src/app/tools @bitwarden/team-tools-dev libs/angular/src/tools @bitwarden/team-tools-dev libs/common/src/models/export @bitwarden/team-tools-dev @@ -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 @@ -223,3 +224,8 @@ apps/web/src/locales/en/messages.json **/jest.config.js @bitwarden/team-platform-dev **/project.jsons @bitwarden/team-platform-dev libs/pricing @bitwarden/team-billing-dev + +# Claude related files +.claude/ @bitwarden/team-ai-sme +.github/workflows/respond.yml @bitwarden/team-ai-sme +.github/workflows/review-code.yml @bitwarden/team-ai-sme diff --git a/.github/renovate.json5 b/.github/renovate.json5 index f898df460c9..ae7c2b023cb 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -139,6 +139,7 @@ "@babel/core", "@babel/preset-env", "@bitwarden/sdk-internal", + "@bitwarden/commercial-sdk-internal", "@electron/fuses", "@electron/notarize", "@electron/rebuild", diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index 5980ef507cc..1c805e8efbe 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -219,12 +219,14 @@ jobs: archive_name_prefix: "" npm_command_prefix: "dist:" readable: "open source license" + type: "oss" - build_prefix: "bit-" artifact_prefix: "bit-" source_archive_name_prefix: "bit-" archive_name_prefix: "bit-" npm_command_prefix: "dist:bit:" readable: "commercial license" + type: "commercial" browser: - name: "chrome" npm_command_suffix: "chrome" @@ -279,6 +281,11 @@ jobs: run: npm ci working-directory: browser-source/ + - name: Remove commercial packages + if: ${{ matrix.license_type.type == 'oss' }} + run: rm -rf node_modules/@bitwarden/commercial-sdk-internal + working-directory: browser-source/ + - name: Download SDK artifacts if: ${{ inputs.sdk_branch != '' }} uses: bitwarden/gh-actions/download-artifacts@main @@ -350,11 +357,13 @@ jobs: archive_name_prefix: "" npm_command_prefix: "dist:" readable: "open source license" + type: "oss" - build_prefix: "bit-" artifact_prefix: "bit-" archive_name_prefix: "bit-" npm_command_prefix: "dist:bit:" readable: "commercial license" + type: "commercial" env: _BUILD_NUMBER: ${{ needs.setup.outputs.adj_build_number }} _NODE_VERSION: ${{ needs.setup.outputs.node_version }} @@ -461,6 +470,11 @@ jobs: run: npm ci working-directory: ./ + - name: Remove commercial packages + if: ${{ matrix.license_type.type == 'oss' }} + run: rm -rf node_modules/@bitwarden/commercial-sdk-internal + working-directory: ./ + - name: Download SDK Artifacts if: ${{ inputs.sdk_branch != '' }} uses: bitwarden/gh-actions/download-artifacts@main diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index 1f7b35f3307..c2abbdf5e5c 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -98,8 +98,8 @@ jobs: ] license_type: [ - { build_prefix: "oss", artifact_prefix: "-oss", readable: "open source license" }, - { build_prefix: "bit", artifact_prefix: "", readable: "commercial license" } + { type: "oss", build_prefix: "oss", artifact_prefix: "-oss", readable: "open source license" }, + { type: "commercial", build_prefix: "bit", artifact_prefix: "", readable: "commercial license" } ] runs-on: ${{ matrix.os.distro }} needs: setup @@ -140,6 +140,11 @@ jobs: run: npm ci working-directory: ./ + - name: Remove commercial packages + if: ${{ matrix.license_type.type == 'oss' }} + run: rm -rf node_modules/@bitwarden/commercial-sdk-internal + working-directory: ./ + - name: Download SDK Artifacts if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }} uses: bitwarden/gh-actions/download-artifacts@main @@ -291,8 +296,8 @@ jobs: matrix: license_type: [ - { build_prefix: "oss", artifact_prefix: "-oss", readable: "open source license" }, - { build_prefix: "bit", artifact_prefix: "", readable: "commercial license" } + { type: "oss", build_prefix: "oss", artifact_prefix: "-oss", readable: "open source license" }, + { type: "commercial", build_prefix: "bit", artifact_prefix: "", readable: "commercial license" } ] runs-on: windows-2022 permissions: @@ -410,6 +415,11 @@ jobs: run: npm ci working-directory: ./ + - name: Remove commercial packages + if: ${{ matrix.license_type.type == 'oss' }} + run: Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "node_modules/@bitwarden/commercial-sdk-internal" + working-directory: ./ + - name: Download SDK Artifacts if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }} uses: bitwarden/gh-actions/download-artifacts@main diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index ee7444f13a9..0ea3ad7af78 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -99,34 +99,43 @@ jobs: matrix: include: - artifact_name: selfhosted-open-source + license_type: "oss" image_name: web-oss npm_command: dist:oss:selfhost - artifact_name: cloud-COMMERCIAL + license_type: "commercial" image_name: web-cloud npm_command: dist:bit:cloud - artifact_name: selfhosted-COMMERCIAL + license_type: "commercial" image_name: web npm_command: dist:bit:selfhost - artifact_name: selfhosted-DEV + license_type: "commercial" image_name: web npm_command: build:bit:selfhost:dev git_metadata: true - artifact_name: cloud-QA + license_type: "commercial" image_name: web-qa-cloud npm_command: build:bit:qa git_metadata: true - artifact_name: ee + license_type: "commercial" image_name: web-ee npm_command: build:bit:ee git_metadata: true - artifact_name: cloud-euprd + license_type: "commercial" image_name: web-euprd npm_command: build:bit:euprd - artifact_name: cloud-euqa + license_type: "commercial" image_name: web-euqa npm_command: build:bit:euqa git_metadata: true - artifact_name: cloud-usdev + license_type: "commercial" image_name: web-usdev npm_command: build:bit:usdev git_metadata: true @@ -269,6 +278,7 @@ jobs: build-args: | NODE_VERSION=${{ env._NODE_VERSION }} NPM_COMMAND=${{ matrix.npm_command }} + LICENSE_TYPE=${{ matrix.license_type }} context: . file: apps/web/Dockerfile load: true diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ae4f4f95aa6..21786339299 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -75,6 +75,9 @@ jobs: - name: Lint unowned dependencies run: npm run lint:dep-ownership + - name: Lint sdk-internal versions + run: npm run lint:sdk-internal-versions + - name: Run linter run: npm run lint @@ -114,3 +117,12 @@ jobs: - name: Cargo sort working-directory: ./apps/desktop/desktop_native run: cargo sort --workspace --check + + - name: Install cargo-deny + uses: taiki-e/install-action@v2 + with: + tool: cargo-deny + + - name: Run cargo deny + working-directory: ./apps/desktop/desktop_native + run: cargo deny --log-level error --all-features check all 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/review-code.yml b/.github/workflows/review-code.yml index 83cbc3bb547..46309af38ea 100644 --- a/.github/workflows/review-code.yml +++ b/.github/workflows/review-code.yml @@ -1,124 +1,20 @@ -name: Review code +name: Code Review on: pull_request: - types: [opened, synchronize, reopened] + types: [opened, synchronize, reopened, ready_for_review] permissions: {} jobs: review: name: Review - runs-on: ubuntu-24.04 + uses: bitwarden/gh-actions/.github/workflows/_review-code.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: contents: read id-token: write pull-requests: write - - steps: - - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Check for Vault team changes - id: check_changes - run: | - # Ensure we have the base branch - git fetch origin ${{ github.base_ref }} - - echo "Comparing changes between origin/${{ github.base_ref }} and HEAD" - CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) - - if [ -z "$CHANGED_FILES" ]; then - echo "Zero files changed" - echo "vault_team_changes=false" >> $GITHUB_OUTPUT - exit 0 - fi - - # Handle variations in spacing and multiple teams - VAULT_PATTERNS=$(grep -E "@bitwarden/team-vault-dev(\s|$)" .github/CODEOWNERS 2>/dev/null | awk '{print $1}') - - if [ -z "$VAULT_PATTERNS" ]; then - echo "⚠️ No patterns found for @bitwarden/team-vault-dev in CODEOWNERS" - echo "vault_team_changes=false" >> $GITHUB_OUTPUT - exit 0 - fi - - vault_team_changes=false - for pattern in $VAULT_PATTERNS; do - echo "Checking pattern: $pattern" - - # Handle **/directory patterns - if [[ "$pattern" == "**/"* ]]; then - # Remove the **/ prefix - dir_pattern="${pattern#\*\*/}" - # Check if any file contains this directory in its path - if echo "$CHANGED_FILES" | grep -qE "(^|/)${dir_pattern}(/|$)"; then - vault_team_changes=true - echo "✅ Found files matching pattern: $pattern" - echo "$CHANGED_FILES" | grep -E "(^|/)${dir_pattern}(/|$)" | sed 's/^/ - /' - break - fi - else - # Handle other patterns (shouldn't happen based on your CODEOWNERS) - if echo "$CHANGED_FILES" | grep -q "$pattern"; then - vault_team_changes=true - echo "✅ Found files matching pattern: $pattern" - echo "$CHANGED_FILES" | grep "$pattern" | sed 's/^/ - /' - break - fi - fi - done - - echo "vault_team_changes=$vault_team_changes" >> $GITHUB_OUTPUT - - if [ "$vault_team_changes" = "true" ]; then - echo "" - echo "✅ Vault team changes detected - proceeding with review" - else - echo "" - echo "❌ No Vault team changes detected - skipping review" - fi - - - name: Review with Claude Code - if: steps.check_changes.outputs.vault_team_changes == 'true' - uses: anthropics/claude-code-action@ac1a3207f3f00b4a37e2f3a6f0935733c7c64651 # v1.0.11 - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - track_progress: true - use_sticky_comment: true - prompt: | - REPO: ${{ github.repository }} - PR NUMBER: ${{ github.event.pull_request.number }} - TITLE: ${{ github.event.pull_request.title }} - BODY: ${{ github.event.pull_request.body }} - AUTHOR: ${{ github.event.pull_request.user.login }} - COMMIT: ${{ github.event.pull_request.head.sha }} - - Please review this pull request with a focus on: - - Code quality and best practices - - Potential bugs or issues - - Security implications - - Performance considerations - - Note: The PR branch is already checked out in the current working directory. - - Provide a comprehensive review including: - - Summary of changes since last review - - Critical issues found (be thorough) - - Suggested improvements (be thorough) - - Good practices observed (be concise - list only the most notable items without elaboration) - - Action items for the author - - Leverage collapsible
sections where appropriate for lengthy explanations or code snippets to enhance human readability - - When reviewing subsequent commits: - - Track status of previously identified issues (fixed/unfixed/reopened) - - Identify NEW problems introduced since last review - - Note if fixes introduced new issues - - IMPORTANT: Be comprehensive about issues and improvements. For good practices, be brief - just note what was done well without explaining why or praising excessively. - - claude_args: | - --allowedTools "mcp__github_comment__update_claude_comment,mcp__github_inline_comment__create_inline_comment,Bash(gh pr diff:*),Bash(gh pr view:*)" 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/.gitignore b/.gitignore index 6b13d22caa7..a88c3bd133b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,6 @@ Thumbs.db *.launch .settings/ *.sublime-workspace -.claude .serena # Visual Studio Code diff --git a/.npmrc b/.npmrc index 421cf18217d..38a7eb153c0 100644 --- a/.npmrc +++ b/.npmrc @@ -1,4 +1,4 @@ save-exact=true # Increase available heap size to avoid running out of memory when compiling. # This applies to all npm scripts in this repository. -node-options=--max-old-space-size=8192 \ No newline at end of file +node-options=--max-old-space-size=8192 diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index ef530563f48..d60652d14d8 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?" }, @@ -1694,9 +1709,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" }, @@ -3258,6 +3294,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4029,6 +4068,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" }, @@ -5739,5 +5787,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/auth/popup/constants/auth-extension-route.constant.ts b/apps/browser/src/auth/popup/constants/auth-extension-route.constant.ts new file mode 100644 index 00000000000..5ea6fac7ebb --- /dev/null +++ b/apps/browser/src/auth/popup/constants/auth-extension-route.constant.ts @@ -0,0 +1,8 @@ +// Full routes that auth owns in the extension +export const AuthExtensionRoute = Object.freeze({ + AccountSecurity: "account-security", + DeviceManagement: "device-management", + AccountSwitcher: "account-switcher", +} as const); + +export type AuthExtensionRoute = (typeof AuthExtensionRoute)[keyof typeof AuthExtensionRoute]; diff --git a/apps/browser/src/auth/popup/constants/index.ts b/apps/browser/src/auth/popup/constants/index.ts new file mode 100644 index 00000000000..59855040fd3 --- /dev/null +++ b/apps/browser/src/auth/popup/constants/index.ts @@ -0,0 +1 @@ +export * from "./auth-extension-route.constant"; 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/autofill/overlay/notifications/content/__snapshots__/overlay-notifications-content.service.spec.ts.snap b/apps/browser/src/autofill/overlay/notifications/content/__snapshots__/overlay-notifications-content.service.spec.ts.snap index e5bafe34b5f..39ca68d912c 100644 --- a/apps/browser/src/autofill/overlay/notifications/content/__snapshots__/overlay-notifications-content.service.spec.ts.snap +++ b/apps/browser/src/autofill/overlay/notifications/content/__snapshots__/overlay-notifications-content.service.spec.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`OverlayNotificationsContentService opening the notification bar creates the notification bar elements and appends them to the body 1`] = ` +exports[`OverlayNotificationsContentService opening the notification bar creates the notification bar elements and appends them to the body within a shadow root 1`] = `
{ let domElementVisibilityService: DomElementVisibilityService; let autofillInit: AutofillInit; let bodyAppendChildSpy: jest.SpyInstance; + let postMessageSpy: jest.SpyInstance>; beforeEach(() => { jest.useFakeTimers(); jest.spyOn(utils, "sendExtensionMessage").mockImplementation(jest.fn()); + jest.spyOn(HTMLIFrameElement.prototype, "contentWindow", "get").mockReturnValue(window); + postMessageSpy = jest.spyOn(window, "postMessage").mockImplementation(jest.fn()); domQueryService = mock(); domElementVisibilityService = new DomElementVisibilityService(); overlayNotificationsContentService = new OverlayNotificationsContentService(); @@ -48,7 +51,7 @@ describe("OverlayNotificationsContentService", () => { }); it("closes the notification bar if the notification bar type has changed", async () => { - overlayNotificationsContentService["currentNotificationBarType"] = "add"; + overlayNotificationsContentService["currentNotificationBarType"] = NotificationType.AddLogin; const closeNotificationBarSpy = jest.spyOn( overlayNotificationsContentService as any, "closeNotificationBar", @@ -66,7 +69,7 @@ describe("OverlayNotificationsContentService", () => { expect(closeNotificationBarSpy).toHaveBeenCalled(); }); - it("creates the notification bar elements and appends them to the body", async () => { + it("creates the notification bar elements and appends them to the body within a shadow root", async () => { sendMockExtensionMessage({ command: "openNotificationBar", data: { @@ -77,6 +80,13 @@ describe("OverlayNotificationsContentService", () => { await flushPromises(); expect(overlayNotificationsContentService["notificationBarElement"]).toMatchSnapshot(); + + const rootElement = overlayNotificationsContentService["notificationBarRootElement"]; + expect(bodyAppendChildSpy).toHaveBeenCalledWith(rootElement); + expect(rootElement?.tagName).toBe("BIT-NOTIFICATION-BAR-ROOT"); + + expect(document.getElementById("bit-notification-bar")).toBeNull(); + expect(document.querySelector("#bit-notification-bar-iframe")).toBeNull(); }); it("sets up a slide in animation when the notification is fresh", async () => { @@ -116,6 +126,8 @@ describe("OverlayNotificationsContentService", () => { }); it("sends an initialization message to the notification bar iframe", async () => { + const addEventListenerSpy = jest.spyOn(globalThis, "addEventListener"); + sendMockExtensionMessage({ command: "openNotificationBar", data: { @@ -124,10 +136,7 @@ describe("OverlayNotificationsContentService", () => { }, }); await flushPromises(); - const postMessageSpy = jest.spyOn( - overlayNotificationsContentService["notificationBarIframeElement"].contentWindow, - "postMessage", - ); + expect(addEventListenerSpy).toHaveBeenCalledWith("message", expect.any(Function)); globalThis.dispatchEvent( new MessageEvent("message", { @@ -142,7 +151,6 @@ describe("OverlayNotificationsContentService", () => { ); await flushPromises(); - expect(postMessageSpy).toHaveBeenCalledTimes(1); expect(postMessageSpy).toHaveBeenCalledWith( { command: "initNotificationBar", @@ -158,7 +166,7 @@ describe("OverlayNotificationsContentService", () => { sendMockExtensionMessage({ command: "openNotificationBar", data: { - type: "change", + type: NotificationType.ChangePassword, typeData: mock(), }, }); @@ -242,20 +250,15 @@ describe("OverlayNotificationsContentService", () => { }); it("sends a message to the notification bar iframe indicating that the save attempt completed", () => { - jest.spyOn( - overlayNotificationsContentService["notificationBarIframeElement"].contentWindow, - "postMessage", - ); - sendMockExtensionMessage({ command: "saveCipherAttemptCompleted", data: { error: undefined }, }); - expect( - overlayNotificationsContentService["notificationBarIframeElement"].contentWindow - .postMessage, - ).toHaveBeenCalledWith({ command: "saveCipherAttemptCompleted", error: undefined }, "*"); + expect(postMessageSpy).toHaveBeenCalledWith( + { command: "saveCipherAttemptCompleted", error: undefined }, + "*", + ); }); }); @@ -271,9 +274,10 @@ describe("OverlayNotificationsContentService", () => { await flushPromises(); }); - it("triggers a closure of the notification bar", () => { + it("triggers a closure of the notification bar and cleans up all shadow DOM elements", () => { overlayNotificationsContentService.destroy(); + expect(overlayNotificationsContentService["notificationBarRootElement"]).toBeNull(); expect(overlayNotificationsContentService["notificationBarElement"]).toBeNull(); expect(overlayNotificationsContentService["notificationBarIframeElement"]).toBeNull(); }); diff --git a/apps/browser/src/autofill/overlay/notifications/content/overlay-notifications-content.service.ts b/apps/browser/src/autofill/overlay/notifications/content/overlay-notifications-content.service.ts index 4e09c3186bb..0afa4f1409b 100644 --- a/apps/browser/src/autofill/overlay/notifications/content/overlay-notifications-content.service.ts +++ b/apps/browser/src/autofill/overlay/notifications/content/overlay-notifications-content.service.ts @@ -17,8 +17,10 @@ import { export class OverlayNotificationsContentService implements OverlayNotificationsContentServiceInterface { + private notificationBarRootElement: HTMLElement | null = null; private notificationBarElement: HTMLElement | null = null; private notificationBarIframeElement: HTMLIFrameElement | null = null; + private notificationBarShadowRoot: ShadowRoot | null = null; private currentNotificationBarType: NotificationType | null = null; private notificationBarContainerStyles: Partial = { height: "400px", @@ -158,12 +160,12 @@ export class OverlayNotificationsContentService * @private */ private openNotificationBar(initData: NotificationBarIframeInitData) { - if (!this.notificationBarElement && !this.notificationBarIframeElement) { + if (!this.notificationBarRootElement && !this.notificationBarIframeElement) { this.createNotificationBarIframeElement(initData); this.createNotificationBarElement(); this.setupInitNotificationBarMessageListener(initData); - globalThis.document.body.appendChild(this.notificationBarElement); + globalThis.document.body.appendChild(this.notificationBarRootElement); } } @@ -213,15 +215,25 @@ export class OverlayNotificationsContentService }; /** - * Creates the container for the notification bar iframe. + * Creates the container for the notification bar iframe with shadow DOM. */ private createNotificationBarElement() { if (this.notificationBarIframeElement) { + this.notificationBarRootElement = globalThis.document.createElement( + "bit-notification-bar-root", + ); + + this.notificationBarShadowRoot = this.notificationBarRootElement.attachShadow({ + mode: "closed", + delegatesFocus: true, + }); + this.notificationBarElement = globalThis.document.createElement("div"); this.notificationBarElement.id = "bit-notification-bar"; setElementStyles(this.notificationBarElement, this.notificationBarContainerStyles, true); + this.notificationBarShadowRoot.appendChild(this.notificationBarElement); this.notificationBarElement.appendChild(this.notificationBarIframeElement); } } @@ -258,7 +270,7 @@ export class OverlayNotificationsContentService * @param closedByUserAction - Whether the notification bar was closed by the user. */ private closeNotificationBar(closedByUserAction: boolean = false) { - if (!this.notificationBarElement && !this.notificationBarIframeElement) { + if (!this.notificationBarRootElement && !this.notificationBarIframeElement) { return; } @@ -267,6 +279,9 @@ export class OverlayNotificationsContentService this.notificationBarElement.remove(); this.notificationBarElement = null; + this.notificationBarShadowRoot = null; + this.notificationBarRootElement.remove(); + this.notificationBarRootElement = null; const removableNotificationTypes = new Set([ NotificationTypes.Add, diff --git a/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts index 07fdfb9db79..63cd4b534fb 100644 --- a/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts @@ -26,7 +26,6 @@ const eventsToTest = [ EVENTS.CHANGE, EVENTS.INPUT, EVENTS.KEYDOWN, - EVENTS.KEYPRESS, EVENTS.KEYUP, "blur", "click", @@ -1044,13 +1043,13 @@ describe("InsertAutofillContentService", () => { }); describe("simulateUserKeyboardEventInteractions", () => { - it("will trigger `keydown`, `keypress`, and `keyup` events on the passed element", () => { + it("will trigger `keydown` and `keyup` events on the passed element", () => { const inputElement = document.querySelector('input[type="text"]') as HTMLInputElement; jest.spyOn(inputElement, "dispatchEvent"); insertAutofillContentService["simulateUserKeyboardEventInteractions"](inputElement); - [EVENTS.KEYDOWN, EVENTS.KEYPRESS, EVENTS.KEYUP].forEach((eventName) => { + [EVENTS.KEYDOWN, EVENTS.KEYUP].forEach((eventName) => { expect(inputElement.dispatchEvent).toHaveBeenCalledWith( new KeyboardEvent(eventName, { bubbles: true }), ); diff --git a/apps/browser/src/autofill/services/insert-autofill-content.service.ts b/apps/browser/src/autofill/services/insert-autofill-content.service.ts index 9ddbcdc005d..6c951afc1a0 100644 --- a/apps/browser/src/autofill/services/insert-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.ts @@ -136,7 +136,7 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf setTimeout(() => { this.autofillInsertActions[action]({ opid, value }); resolve(); - }, delayActionsInMilliseconds * actionIndex), + }, delayActionsInMilliseconds), ); }; @@ -349,7 +349,7 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf * @private */ private simulateUserKeyboardEventInteractions(element: FormFieldElement): void { - const simulatedKeyboardEvents = [EVENTS.KEYDOWN, EVENTS.KEYPRESS, EVENTS.KEYUP]; + const simulatedKeyboardEvents = [EVENTS.KEYDOWN, EVENTS.KEYUP]; for (let index = 0; index < simulatedKeyboardEvents.length; index++) { element.dispatchEvent(new KeyboardEvent(simulatedKeyboardEvents[index], { bubbles: true })); } diff --git a/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.ts b/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.ts index 4712c94c89e..6087042629a 100644 --- a/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.ts +++ b/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.ts @@ -21,6 +21,8 @@ import { import { PhishingDetectionService } from "../services/phishing-detection.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: "dirt-phishing-warning", standalone: true, diff --git a/apps/browser/src/dirt/phishing-detection/pages/protected-by-component.ts b/apps/browser/src/dirt/phishing-detection/pages/protected-by-component.ts index 298c7acd38e..71cdac89aa2 100644 --- a/apps/browser/src/dirt/phishing-detection/pages/protected-by-component.ts +++ b/apps/browser/src/dirt/phishing-detection/pages/protected-by-component.ts @@ -6,6 +6,8 @@ import { Component } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ButtonModule, LinkModule } from "@bitwarden/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: "dirt-phishing-protected-by", standalone: true, diff --git a/apps/browser/src/platform/popup/components/pop-out.component.ts b/apps/browser/src/platform/popup/components/pop-out.component.ts index 320fa6f05ab..fd2acbd8aa7 100644 --- a/apps/browser/src/platform/popup/components/pop-out.component.ts +++ b/apps/browser/src/platform/popup/components/pop-out.component.ts @@ -7,12 +7,16 @@ import { IconButtonModule } from "@bitwarden/components"; import BrowserPopupUtils from "../../browser/browser-popup-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-pop-out", templateUrl: "pop-out.component.html", imports: [CommonModule, JslibModule, IconButtonModule], }) export class PopOutComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() show = true; constructor(private platformUtilsService: PlatformUtilsService) {} diff --git a/apps/browser/src/platform/popup/services/browser-file-download.service.ts b/apps/browser/src/platform/popup/services/browser-file-download.service.ts index ec04adac2af..a30c7fe02c8 100644 --- a/apps/browser/src/platform/popup/services/browser-file-download.service.ts +++ b/apps/browser/src/platform/popup/services/browser-file-download.service.ts @@ -15,23 +15,9 @@ export class BrowserFileDownloadService implements FileDownloadService { download(request: FileDownloadRequest): void { const builder = new FileDownloadBuilder(request); if (BrowserApi.isSafariApi) { - let data: BlobPart = null; - if (builder.blobOptions.type === "text/plain" && typeof request.blobData === "string") { - data = request.blobData; - } else { - data = Utils.fromBufferToB64(request.blobData as ArrayBuffer); - } - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - SafariApp.sendMessageToApp( - "downloadFile", - JSON.stringify({ - blobData: data, - blobOptions: request.blobOptions, - fileName: request.fileName, - }), - true, - ); + // Handle Safari download asynchronously to allow Blob conversion + // This function can't be async because the interface is not async + void this.downloadSafari(request, builder); } else { const a = window.document.createElement("a"); a.href = URL.createObjectURL(builder.blob); @@ -41,4 +27,31 @@ export class BrowserFileDownloadService implements FileDownloadService { window.document.body.removeChild(a); } } + + private async downloadSafari( + request: FileDownloadRequest, + builder: FileDownloadBuilder, + ): Promise { + let data: string = null; + if (builder.blobOptions.type === "text/plain" && typeof request.blobData === "string") { + data = request.blobData; + } else if (request.blobData instanceof Blob) { + // Convert Blob to ArrayBuffer first, then to Base64 + const arrayBuffer = await request.blobData.arrayBuffer(); + data = Utils.fromBufferToB64(arrayBuffer); + } else { + // Already an ArrayBuffer + data = Utils.fromBufferToB64(request.blobData as ArrayBuffer); + } + + await SafariApp.sendMessageToApp( + "downloadFile", + JSON.stringify({ + blobData: data, + blobOptions: request.blobOptions, + fileName: request.fileName, + }), + true, + ); + } } diff --git a/apps/browser/src/platform/popup/view-cache/popup-router-cache.spec.ts b/apps/browser/src/platform/popup/view-cache/popup-router-cache.spec.ts index 3304a99023e..835a8eebd2c 100644 --- a/apps/browser/src/platform/popup/view-cache/popup-router-cache.spec.ts +++ b/apps/browser/src/platform/popup/view-cache/popup-router-cache.spec.ts @@ -13,6 +13,8 @@ import { PopupRouterCacheService, popupRouterCacheGuard } from "./popup-router-c const flushPromises = async () => await new Promise(process.nextTick); +// 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/apps/browser/src/platform/popup/view-cache/popup-view-cache.spec.ts b/apps/browser/src/platform/popup/view-cache/popup-view-cache.spec.ts index 60baf94eeae..a18d51878ee 100644 --- a/apps/browser/src/platform/popup/view-cache/popup-view-cache.spec.ts +++ b/apps/browser/src/platform/popup/view-cache/popup-view-cache.spec.ts @@ -19,12 +19,16 @@ import { import { PopupViewCacheService } from "./popup-view-cache.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({ template: "", standalone: false, }) export class EmptyComponent {} +// 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/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 02adaff9b83..1834beb391e 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -2,6 +2,7 @@ import { Injectable, NgModule } from "@angular/core"; import { ActivatedRouteSnapshot, RouteReuseStrategy, RouterModule, Routes } from "@angular/router"; import { AuthenticationTimeoutComponent } from "@bitwarden/angular/auth/components/authentication-timeout.component"; +import { AuthRoute } from "@bitwarden/angular/auth/constants"; import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/environment-selector/environment-selector.component"; import { activeAuthGuard, @@ -45,6 +46,7 @@ import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/co import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui"; import { AccountSwitcherComponent } from "../auth/popup/account-switching/account-switcher.component"; +import { AuthExtensionRoute } from "../auth/popup/constants/auth-extension-route.constant"; import { fido2AuthGuard } from "../auth/popup/guards/fido2-auth.guard"; import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component"; import { ExtensionDeviceManagementComponent } from "../auth/popup/settings/extension-device-management.component"; @@ -148,7 +150,7 @@ const routes: Routes = [ component: ExtensionAnonLayoutWrapperComponent, children: [ { - path: "authentication-timeout", + path: AuthRoute.AuthenticationTimeout, canActivate: [unauthGuardFn(unauthRouteOverrides)], children: [ { @@ -167,7 +169,7 @@ const routes: Routes = [ ], }, { - path: "device-verification", + path: AuthRoute.NewDeviceVerification, component: ExtensionAnonLayoutWrapperComponent, canActivate: [unauthGuardFn(), activeAuthGuard()], children: [{ path: "", component: NewDeviceVerificationComponent }], @@ -259,13 +261,13 @@ const routes: Routes = [ data: { elevation: 1 } satisfies RouteDataProperties, }, { - path: "account-security", + path: AuthExtensionRoute.AccountSecurity, component: AccountSecurityComponent, canActivate: [authGuard], data: { elevation: 1 } satisfies RouteDataProperties, }, { - path: "device-management", + path: AuthExtensionRoute.DeviceManagement, component: ExtensionDeviceManagementComponent, canActivate: [authGuard], data: { elevation: 1 } satisfies RouteDataProperties, @@ -341,7 +343,7 @@ const routes: Routes = [ component: ExtensionAnonLayoutWrapperComponent, children: [ { - path: "signup", + path: AuthRoute.SignUp, canActivate: [unauthGuardFn()], data: { elevation: 1, @@ -361,13 +363,13 @@ const routes: Routes = [ component: RegistrationStartSecondaryComponent, outlet: "secondary", data: { - loginRoute: "/login", + loginRoute: `/${AuthRoute.Login}`, } satisfies RegistrationStartSecondaryComponentData, }, ], }, { - path: "finish-signup", + path: AuthRoute.FinishSignUp, canActivate: [unauthGuardFn()], data: { pageIcon: LockIcon, @@ -382,7 +384,7 @@ const routes: Routes = [ ], }, { - path: "set-initial-password", + path: AuthRoute.SetInitialPassword, canActivate: [authGuard], component: SetInitialPasswordComponent, data: { @@ -390,7 +392,7 @@ const routes: Routes = [ } satisfies RouteDataProperties, }, { - path: "login", + path: AuthRoute.Login, canActivate: [unauthGuardFn(unauthRouteOverrides), IntroCarouselGuard], data: { pageIcon: VaultIcon, @@ -411,7 +413,7 @@ const routes: Routes = [ ], }, { - path: "login-with-passkey", + path: AuthRoute.LoginWithPasskey, canActivate: [unauthGuardFn(unauthRouteOverrides)], data: { pageIcon: TwoFactorAuthSecurityKeyIcon, @@ -434,7 +436,7 @@ const routes: Routes = [ ], }, { - path: "sso", + path: AuthRoute.Sso, canActivate: [unauthGuardFn(unauthRouteOverrides)], data: { pageIcon: VaultIcon, @@ -456,7 +458,7 @@ const routes: Routes = [ ], }, { - path: "login-with-device", + path: AuthRoute.LoginWithDevice, canActivate: [redirectToVaultIfUnlockedGuard()], data: { pageIcon: DevicesIcon, @@ -479,7 +481,7 @@ const routes: Routes = [ ], }, { - path: "hint", + path: AuthRoute.PasswordHint, canActivate: [unauthGuardFn(unauthRouteOverrides)], data: { pageTitle: { @@ -502,7 +504,7 @@ const routes: Routes = [ ], }, { - path: "admin-approval-requested", + path: AuthRoute.AdminApprovalRequested, canActivate: [redirectToVaultIfUnlockedGuard()], data: { pageIcon: DevicesIcon, @@ -519,7 +521,7 @@ const routes: Routes = [ children: [{ path: "", component: LoginViaAuthRequestComponent }], }, { - path: "login-initiated", + path: AuthRoute.LoginInitiated, canActivate: [tdeDecryptionRequiredGuard()], data: { pageIcon: DevicesIcon, @@ -557,7 +559,7 @@ const routes: Routes = [ ], }, { - path: "2fa", + path: AuthRoute.TwoFactor, canActivate: [unauthGuardFn(unauthRouteOverrides), TwoFactorAuthGuard], children: [ { @@ -576,7 +578,7 @@ const routes: Routes = [ } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, }, { - path: "change-password", + path: AuthRoute.ChangePassword, data: { elevation: 1, hideFooter: true, @@ -698,7 +700,7 @@ const routes: Routes = [ canActivate: [authGuard, canAccessAtRiskPasswords, hasAtRiskPasswords], }, { - path: "account-switcher", + path: AuthExtensionRoute.AccountSwitcher, component: AccountSwitcherComponent, data: { elevation: 4, doNotSaveUrl: true } satisfies RouteDataProperties, }, diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index b85da665fa0..8f00569b720 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -67,6 +67,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/safari/safari/SafariWebExtensionHandler.swift b/apps/browser/src/safari/safari/SafariWebExtensionHandler.swift index 54e91611325..dad1e6855fc 100644 --- a/apps/browser/src/safari/safari/SafariWebExtensionHandler.swift +++ b/apps/browser/src/safari/safari/SafariWebExtensionHandler.swift @@ -69,8 +69,8 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { if let url = panel.url { do { let fileManager = FileManager.default - if !fileManager.fileExists(atPath: url.absoluteString) { - fileManager.createFile(atPath: url.absoluteString, contents: Data(), + if !fileManager.fileExists(atPath: url.path) { + fileManager.createFile(atPath: url.path, contents: Data(), attributes: nil) } try data.write(to: url) diff --git a/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts index c3d4f461d70..c37131b3ff1 100644 --- a/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts +++ b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts @@ -10,6 +10,8 @@ import { AnchorLinkDirective, CalloutModule, BannerModule } from "@bitwarden/com import { I18nPipe } from "@bitwarden/ui-common"; import { AtRiskPasswordCalloutData, AtRiskPasswordCalloutService } from "@bitwarden/vault"; +// 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: "vault-at-risk-password-callout", imports: [ diff --git a/apps/browser/src/vault/popup/components/at-risk-carousel-dialog/at-risk-carousel-dialog.component.ts b/apps/browser/src/vault/popup/components/at-risk-carousel-dialog/at-risk-carousel-dialog.component.ts index 08c466d21a9..f81bccc760c 100644 --- a/apps/browser/src/vault/popup/components/at-risk-carousel-dialog/at-risk-carousel-dialog.component.ts +++ b/apps/browser/src/vault/popup/components/at-risk-carousel-dialog/at-risk-carousel-dialog.component.ts @@ -17,6 +17,8 @@ export const AtRiskCarouselDialogResult = { type AtRiskCarouselDialogResult = UnionOfValues; +// 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: "vault-at-risk-carousel-dialog", templateUrl: "./at-risk-carousel-dialog.component.html", @@ -32,6 +34,8 @@ type AtRiskCarouselDialogResult = UnionOfValues`, + changeDetection: ChangeDetectionStrategy.OnPush, }) class MockPopupHeaderComponent { - @Input() pageTitle: string | undefined; - @Input() backAction: (() => void) | undefined; + readonly pageTitle = input(undefined); + readonly backAction = input<(() => void) | undefined>(undefined); } @Component({ selector: "popup-page", template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, }) class MockPopupPageComponent { - @Input() loading: boolean | undefined; + readonly loading = input(undefined); } @Component({ selector: "app-vault-icon", template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, }) class MockAppIcon { - @Input() cipher: CipherView | undefined; + readonly cipher = input(undefined); } describe("AtRiskPasswordsComponent", () => { @@ -95,11 +98,15 @@ describe("AtRiskPasswordsComponent", () => { id: "cipher", organizationId: "org", name: "Item 1", + edit: true, + viewPassword: true, } as CipherView, { id: "cipher2", organizationId: "org", name: "Item 2", + edit: true, + viewPassword: true, } as CipherView, ]); mockOrgs$ = new BehaviorSubject([ @@ -221,6 +228,38 @@ describe("AtRiskPasswordsComponent", () => { organizationId: "org", name: "Item 1", isDeleted: true, + edit: true, + viewPassword: true, + } as CipherView, + ]); + + const items = await firstValueFrom(component["atRiskItems$"]); + expect(items).toHaveLength(0); + }); + + it("should not show tasks when cipher does not have edit permission", async () => { + mockCiphers$.next([ + { + id: "cipher", + organizationId: "org", + name: "Item 1", + edit: false, + viewPassword: true, + } as CipherView, + ]); + + const items = await firstValueFrom(component["atRiskItems$"]); + expect(items).toHaveLength(0); + }); + + it("should not show tasks when cipher does not have viewPassword permission", async () => { + mockCiphers$.next([ + { + id: "cipher", + organizationId: "org", + name: "Item 1", + edit: true, + viewPassword: false, } as CipherView, ]); @@ -274,11 +313,15 @@ describe("AtRiskPasswordsComponent", () => { id: "cipher", organizationId: "org", name: "Item 1", + edit: true, + viewPassword: true, } as CipherView, { id: "cipher2", organizationId: "org2", name: "Item 2", + edit: true, + viewPassword: true, } as CipherView, ]); diff --git a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts index 6918bedb9bf..94fdb00f566 100644 --- a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts +++ b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts @@ -1,5 +1,12 @@ import { CommonModule } from "@angular/common"; -import { Component, DestroyRef, inject, OnInit, signal } from "@angular/core"; +import { + Component, + DestroyRef, + inject, + OnInit, + signal, + ChangeDetectionStrategy, +} from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Router } from "@angular/router"; import { @@ -80,6 +87,7 @@ import { AtRiskPasswordPageService } from "./at-risk-password-page.service"; ], selector: "vault-at-risk-passwords", templateUrl: "./at-risk-passwords.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, }) export class AtRiskPasswordsComponent implements OnInit { private taskService = inject(TaskService); @@ -156,6 +164,8 @@ export class AtRiskPasswordsComponent implements OnInit { t.type === SecurityTaskType.UpdateAtRiskCredential && t.cipherId != null && ciphers[t.cipherId] != null && + ciphers[t.cipherId].edit && + ciphers[t.cipherId].viewPassword && !ciphers[t.cipherId].isDeleted, ) .map((t) => ciphers[t.cipherId!]), diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts index 463819b96e4..60e44cefbdf 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts @@ -131,6 +131,8 @@ class QueryParams { export type AddEditQueryParams = Partial>; +// 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-add-edit-v2", templateUrl: "add-edit-v2.component.html", diff --git a/apps/browser/src/vault/popup/components/vault-v2/assign-collections/assign-collections.component.ts b/apps/browser/src/vault/popup/components/vault-v2/assign-collections/assign-collections.component.ts index 0b7346c8613..b314c48fecd 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/assign-collections/assign-collections.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/assign-collections/assign-collections.component.ts @@ -28,6 +28,8 @@ import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.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-assign-collections", templateUrl: "./assign-collections.component.html", diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts index 6e4215c1ec2..871163ac80b 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts @@ -25,20 +25,30 @@ import { PopupRouterCacheService } from "../../../../../platform/popup/view-cach import { AttachmentsV2Component } from "./attachments-v2.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: "popup-header", template: ``, }) class MockPopupHeaderComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() pageTitle: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() backAction: () => void; } +// 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: "popup-footer", template: ``, }) class MockPopupFooterComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() pageTitle: string; } diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.ts index fc6d882dfd5..295496c701f 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.ts @@ -17,6 +17,8 @@ import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component"; import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.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-attachments-v2", templateUrl: "./attachments-v2.component.html", diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts index 26410a46187..e2af3c44c7e 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts @@ -25,6 +25,8 @@ import { CipherFormContainer } from "@bitwarden/vault"; import BrowserPopupUtils from "../../../../../../platform/browser/browser-popup-utils"; import { FilePopoutUtilsService } from "../../../../../../tools/popup/services/file-popout-utils.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-open-attachments", templateUrl: "./open-attachments.component.html", @@ -39,6 +41,8 @@ import { FilePopoutUtilsService } from "../../../../../../tools/popup/services/f }) export class OpenAttachmentsComponent implements OnInit { /** Cipher `id` */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) cipherId: CipherId; /** True when the attachments window should be opened in a popout */ 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/autofill-vault-list-items/autofill-vault-list-items.component.ts b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts index 1eef907821d..64f662ab840 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts @@ -15,6 +15,8 @@ import { VaultPopupItemsService } from "../../../services/vault-popup-items.serv import { PopupCipherViewLike } from "../../../views/popup-cipher.view"; import { VaultListItemsContainerComponent } from "../vault-list-items-container/vault-list-items-container.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({ imports: [ CommonModule, @@ -46,7 +48,7 @@ export class AutofillVaultListItemsComponent { startWith(true), // Start with true to avoid flashing the fill button on first load ); - protected groupByType = toSignal( + protected readonly groupByType = toSignal( this.vaultPopupItemsService.hasFilterApplied$.pipe(map((hasFilter) => !hasFilter)), ); diff --git a/apps/browser/src/vault/popup/components/vault-v2/blocked-injection-banner/blocked-injection-banner.component.ts b/apps/browser/src/vault/popup/components/vault-v2/blocked-injection-banner/blocked-injection-banner.component.ts index 5824e8d97ea..2125af289a2 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/blocked-injection-banner/blocked-injection-banner.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/blocked-injection-banner/blocked-injection-banner.component.ts @@ -15,6 +15,8 @@ import { VaultPopupAutofillService } from "../../../services/vault-popup-autofil const blockedURISettingsRoute = "/blocked-domains"; +// 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: [ BannerModule, diff --git a/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.ts b/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.ts index 94996a054e6..48c8f5682bc 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.ts @@ -9,6 +9,8 @@ import { VaultCarouselModule } from "@bitwarden/vault"; import { IntroCarouselService } from "../../../services/intro-carousel.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-intro-carousel", templateUrl: "./intro-carousel.component.html", diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts index 6c7e8bcfbc3..2e2ee5cd56b 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts @@ -21,6 +21,8 @@ type CipherItem = { field: CopyAction; }; +// 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-item-copy-actions", templateUrl: "item-copy-actions.component.html", @@ -35,6 +37,8 @@ type CipherItem = { }) export class ItemCopyActionsComponent { protected showQuickCopyActions$ = inject(VaultPopupCopyButtonsService).showQuickCopyActions$; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) cipher!: CipherViewLike; protected CipherViewLikeUtils = CipherViewLikeUtils; 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 1b8403e6024..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,16 +34,25 @@ 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 @Component({ selector: "app-item-more-options", templateUrl: "./item-more-options.component.html", 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 @Input({ required: true, }) @@ -57,18 +68,29 @@ export class ItemMoreOptionsComponent { * Flag to show view item menu option. Used when something else is * assigned as the primary action for the item, such as autofill. */ + // 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 * already in the autofill list suggestion. */ + // 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 @@ -138,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() { @@ -169,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() { @@ -196,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/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts index d1586bd6ad5..004980db181 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts @@ -23,6 +23,8 @@ export interface NewItemInitialValues { collectionId?: CollectionId; } +// 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-new-item-dropdown", templateUrl: "new-item-dropdown-v2.component.html", @@ -34,6 +36,8 @@ export class NewItemDropdownV2Component implements OnInit { /** * Optional initial values to pass to the add cipher form */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() initialValues: NewItemInitialValues; diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts index b65138dac3a..2139b6d9a4f 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts @@ -18,14 +18,24 @@ import { VaultGeneratorDialogComponent, } from "./vault-generator-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: "vault-cipher-form-generator", template: "", }) class MockCipherFormGenerator { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() type: "password" | "username" = "password"; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() algorithmSelected: EventEmitter = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() uri: string = ""; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() valueGenerated = new EventEmitter(); } diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.ts index b0103aaacfb..caeebdabc09 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.ts @@ -38,6 +38,8 @@ export const GeneratorDialogAction = { type GeneratorDialogAction = UnionOfValues; +// 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-vault-generator-dialog", templateUrl: "./vault-generator-dialog.component.html", diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.ts index f64b5e6b83d..6381b8be147 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.ts @@ -17,6 +17,8 @@ import { VaultPopupListFiltersService } from "../../../../../vault/popup/service import { VaultListFiltersComponent } from "../vault-list-filters/vault-list-filters.component"; import { VaultV2SearchComponent } from "../vault-search/vault-v2-search.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-vault-header-v2", templateUrl: "vault-header-v2.component.html", @@ -31,6 +33,8 @@ import { VaultV2SearchComponent } from "../vault-search/vault-v2-search.componen ], }) export class VaultHeaderV2Component { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(DisclosureComponent) disclosure: DisclosureComponent; /** Emits the visibility status of the disclosure component. */ diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.ts index 81fad896ad2..50da66fe5b8 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.ts @@ -8,6 +8,8 @@ import { ChipSelectComponent } from "@bitwarden/components"; import { VaultPopupListFiltersService } from "../../../services/vault-popup-list-filters.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-vault-list-filters", templateUrl: "./vault-list-filters.component.html", diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts index 61d7815d93e..6850a474af5 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts @@ -90,12 +90,18 @@ export class VaultListItemsContainerComponent implements AfterViewInit { private vaultPopupSectionService = inject(VaultPopupSectionService); protected CipherViewLikeUtils = CipherViewLikeUtils; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(CdkVirtualScrollViewport, { static: false }) viewPort!: CdkVirtualScrollViewport; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(DisclosureComponent) disclosure!: DisclosureComponent; /** * Indicates whether the section should be open or closed if collapsibleKey is provided */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals protected sectionOpenState: Signal = computed(() => { if (!this.collapsibleKey()) { return true; @@ -130,17 +136,23 @@ export class VaultListItemsContainerComponent implements AfterViewInit { */ private viewCipherTimeout?: number; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals ciphers = input([]); /** * If true, we will group ciphers by type (Login, Card, Identity) * within subheadings in a single container, converted to a WritableSignal. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals groupByType = input(false); /** * Computed signal for a grouped list of ciphers with an optional header */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals cipherGroups = computed< { subHeaderKey?: string; @@ -183,6 +195,8 @@ export class VaultListItemsContainerComponent implements AfterViewInit { /** * Title for the vault list item section. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals title = input(undefined); /** @@ -191,33 +205,45 @@ export class VaultListItemsContainerComponent implements AfterViewInit { * The key must be added to the state definition in `vault-popup-section.service.ts` since the * collapsed state is stored locally. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals collapsibleKey = input(undefined); /** * Optional description for the vault list item section. Will be shown below the title even when * no ciphers are available. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals description = input(undefined); /** * Option to show a refresh button in the section header. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals showRefresh = input(false, { transform: booleanAttribute }); /** * Event emitted when the refresh button is clicked. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onRefresh = new EventEmitter(); /** * Flag indicating that the current tab location is blocked */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals currentURIIsBlocked = toSignal(this.vaultPopupAutofillService.currentTabIsOnBlocklist$); /** * Resolved i18n key to use for suggested cipher items */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals cipherItemTitleKey = computed(() => { return (cipher: CipherViewLike) => { const login = CipherViewLikeUtils.getLogin(cipher); @@ -233,11 +259,15 @@ export class VaultListItemsContainerComponent implements AfterViewInit { /** * Option to show the autofill button for each item. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals showAutofillButton = input(false, { transform: booleanAttribute }); /** * Flag indicating whether the suggested cipher item autofill button should be shown or not */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals hideAutofillButton = computed( () => !this.showAutofillButton() || this.currentURIIsBlocked() || this.primaryActionAutofill(), ); @@ -245,22 +275,30 @@ export class VaultListItemsContainerComponent implements AfterViewInit { /** * Flag indicating whether the cipher item autofill menu options should be shown or not */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals hideAutofillMenuOptions = computed(() => this.currentURIIsBlocked() || this.showAutofillButton()); /** * Option to perform autofill operation as the primary action for autofill suggestions. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals primaryActionAutofill = input(false, { transform: booleanAttribute }); /** * Remove the bottom margin from the bit-section in this component * (used for containers at the end of the page where bottom margin is not needed) */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals disableSectionMargin = input(false, { transform: booleanAttribute }); /** * Remove the description margin */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals disableDescriptionMargin = input(false, { transform: booleanAttribute }); /** @@ -275,6 +313,8 @@ export class VaultListItemsContainerComponent implements AfterViewInit { return collections[0]?.name; } + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals protected autofillShortcutTooltip = signal(undefined); constructor( diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts index f2764df7ba7..7b9f358c01c 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts @@ -18,6 +18,8 @@ import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component"; import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.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: "vault-password-history-v2", templateUrl: "vault-password-history-v2.component.html", diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts index 72df3cba41a..c254c290915 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts @@ -10,6 +10,8 @@ import { SearchModule } from "@bitwarden/components"; import { VaultPopupItemsService } from "../../../services/vault-popup-items.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({ imports: [CommonModule, SearchModule, JslibModule, FormsModule], selector: "app-vault-v2-search", diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts index 604cc6b73ef..2dd6c1a0ce1 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts @@ -64,6 +64,8 @@ const VaultState = { type VaultState = UnionOfValues; +// 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-vault", templateUrl: "vault-v2.component.html", @@ -89,6 +91,8 @@ type VaultState = UnionOfValues; ], }) export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(CdkVirtualScrollableElement) virtualScrollElement?: CdkVirtualScrollableElement; NudgeType = NudgeType; diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts index 915a27e4fd1..30074777e83 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts @@ -76,6 +76,8 @@ type LoadAction = | typeof COPY_VERIFICATION_CODE_ID | typeof UPDATE_PASSWORD; +// 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-view-v2", templateUrl: "view-v2.component.html", diff --git a/apps/browser/src/vault/popup/guards/clear-vault-state.guard.spec.ts b/apps/browser/src/vault/popup/guards/clear-vault-state.guard.spec.ts new file mode 100644 index 00000000000..7ead8576b37 --- /dev/null +++ b/apps/browser/src/vault/popup/guards/clear-vault-state.guard.spec.ts @@ -0,0 +1,77 @@ +import { TestBed } from "@angular/core/testing"; +import { RouterStateSnapshot } from "@angular/router"; + +import { VaultV2Component } from "../components/vault-v2/vault-v2.component"; +import { VaultPopupItemsService } from "../services/vault-popup-items.service"; +import { VaultPopupListFiltersService } from "../services/vault-popup-list-filters.service"; + +import { clearVaultStateGuard } from "./clear-vault-state.guard"; + +describe("clearVaultStateGuard", () => { + let applyFilterSpy: jest.Mock; + let resetFilterFormSpy: jest.Mock; + + beforeEach(() => { + applyFilterSpy = jest.fn(); + resetFilterFormSpy = jest.fn(); + + TestBed.configureTestingModule({ + providers: [ + { + provide: VaultPopupItemsService, + useValue: { applyFilter: applyFilterSpy }, + }, + { + provide: VaultPopupListFiltersService, + useValue: { resetFilterForm: resetFilterFormSpy }, + }, + ], + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + "/view-cipher?cipherId=123", + "/edit-cipher?cipherId=123", + "/clone-cipher?cipherId=123", + "/assign-collections?cipherId=123", + ])("should not clear vault state when viewing or editing a cipher: %s", (url) => { + const nextState = { url } as RouterStateSnapshot; + + const result = TestBed.runInInjectionContext(() => + clearVaultStateGuard({} as VaultV2Component, null, null, nextState), + ); + + expect(result).toBe(true); + expect(applyFilterSpy).not.toHaveBeenCalled(); + expect(resetFilterFormSpy).not.toHaveBeenCalled(); + }); + + it.each(["/settings", "/tabs/settings"])( + "should clear vault state when navigating to non-cipher routes: %s", + (url) => { + const nextState = { url } as RouterStateSnapshot; + + const result = TestBed.runInInjectionContext(() => + clearVaultStateGuard({} as VaultV2Component, null, null, nextState), + ); + + expect(result).toBe(true); + expect(applyFilterSpy).toHaveBeenCalledWith(""); + expect(resetFilterFormSpy).toHaveBeenCalled(); + }, + ); + + it("should not clear vault state when not changing states", () => { + const result = TestBed.runInInjectionContext(() => + clearVaultStateGuard({} as VaultV2Component, null, null, null), + ); + + expect(result).toBe(true); + expect(applyFilterSpy).not.toHaveBeenCalled(); + expect(resetFilterFormSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/browser/src/vault/popup/guards/clear-vault-state.guard.ts b/apps/browser/src/vault/popup/guards/clear-vault-state.guard.ts index e27090180d6..2a87db6e903 100644 --- a/apps/browser/src/vault/popup/guards/clear-vault-state.guard.ts +++ b/apps/browser/src/vault/popup/guards/clear-vault-state.guard.ts @@ -7,7 +7,8 @@ import { VaultPopupListFiltersService } from "../services/vault-popup-list-filte /** * Guard to clear the vault state (search and filter) when navigating away from the vault view. - * This ensures the search and filter state is reset when navigating between different tabs, except viewing a cipher. + * This ensures the search and filter state is reset when navigating between different tabs, + * except viewing or editing a cipher. */ export const clearVaultStateGuard: CanDeactivateFn = ( component: VaultV2Component, @@ -17,7 +18,7 @@ export const clearVaultStateGuard: CanDeactivateFn = ( ) => { const vaultPopupItemsService = inject(VaultPopupItemsService); const vaultPopupListFiltersService = inject(VaultPopupListFiltersService); - if (nextState && !isViewingCipher(nextState.url)) { + if (nextState && !isCipherOpen(nextState.url)) { vaultPopupItemsService.applyFilter(""); vaultPopupListFiltersService.resetFilterForm(); } @@ -25,4 +26,8 @@ export const clearVaultStateGuard: CanDeactivateFn = ( return true; }; -const isViewingCipher = (url: string): boolean => url.includes("view-cipher"); +const isCipherOpen = (url: string): boolean => + url.includes("view-cipher") || + url.includes("assign-collections") || + url.includes("edit-cipher") || + url.includes("clone-cipher"); 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/browser/src/vault/popup/services/vault-popup-section.service.ts b/apps/browser/src/vault/popup/services/vault-popup-section.service.ts index ed641e0cdf7..b93eda72506 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-section.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-section.service.ts @@ -31,7 +31,7 @@ export class VaultPopupSectionService { private vaultPopupItemsService = inject(VaultPopupItemsService); private stateProvider = inject(StateProvider); - private hasFilterOrSearchApplied = toSignal( + private readonly hasFilterOrSearchApplied = toSignal( this.vaultPopupItemsService.hasFilterApplied$.pipe(map((hasFilter) => hasFilter)), ); @@ -40,7 +40,7 @@ export class VaultPopupSectionService { * application-applied overrides. * `null` means there is no current override */ - private temporaryStateOverride = signal | null>(null); + private readonly temporaryStateOverride = signal | null>(null); constructor() { effect( @@ -71,7 +71,7 @@ export class VaultPopupSectionService { * Stored disk state for the open/close state of the sections, with an initial value provided * if the stored disk state does not yet exist. */ - private sectionOpenStoredState = toSignal( + private readonly sectionOpenStoredState = toSignal( this.sectionOpenStateProvider.state$.pipe(map((sectionOpen) => sectionOpen ?? INITIAL_OPEN)), // Indicates that the state value is loading { initialValue: null }, @@ -81,7 +81,7 @@ export class VaultPopupSectionService { * Indicates the current open/close display state of each section, accounting for temporary * non-persisted overrides. */ - sectionOpenDisplayState: Signal> = computed(() => ({ + readonly sectionOpenDisplayState: Signal> = computed(() => ({ ...this.sectionOpenStoredState(), ...this.temporaryStateOverride(), })); diff --git a/apps/browser/src/vault/popup/settings/appearance-v2.component.spec.ts b/apps/browser/src/vault/popup/settings/appearance-v2.component.spec.ts index 738ec3ae1ff..9e1beab5787 100644 --- a/apps/browser/src/vault/popup/settings/appearance-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/settings/appearance-v2.component.spec.ts @@ -22,20 +22,30 @@ import { VaultPopupCopyButtonsService } from "../services/vault-popup-copy-butto import { AppearanceV2Component } from "./appearance-v2.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: "popup-header", template: ``, }) class MockPopupHeaderComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() pageTitle: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() backAction: () => void; } +// 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: "popup-page", template: ``, }) class MockPopupPageComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() loading: boolean; } diff --git a/apps/browser/src/vault/popup/settings/appearance-v2.component.ts b/apps/browser/src/vault/popup/settings/appearance-v2.component.ts index 23a609bd008..e6515ae7461 100644 --- a/apps/browser/src/vault/popup/settings/appearance-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/appearance-v2.component.ts @@ -33,6 +33,8 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co import { PopupSizeService } from "../../../platform/popup/layout/popup-size.service"; import { VaultPopupCopyButtonsService } from "../services/vault-popup-copy-buttons.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: "./appearance-v2.component.html", imports: [ diff --git a/apps/browser/src/vault/popup/settings/archive.component.ts b/apps/browser/src/vault/popup/settings/archive.component.ts index 2044389f295..58925eda428 100644 --- a/apps/browser/src/vault/popup/settings/archive.component.ts +++ b/apps/browser/src/vault/popup/settings/archive.component.ts @@ -33,6 +33,8 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.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({ templateUrl: "archive.component.html", standalone: true, diff --git a/apps/browser/src/vault/popup/settings/download-bitwarden.component.ts b/apps/browser/src/vault/popup/settings/download-bitwarden.component.ts index d23d00a1ad7..109f3ea0404 100644 --- a/apps/browser/src/vault/popup/settings/download-bitwarden.component.ts +++ b/apps/browser/src/vault/popup/settings/download-bitwarden.component.ts @@ -13,6 +13,8 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.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({ templateUrl: "download-bitwarden.component.html", imports: [ diff --git a/apps/browser/src/vault/popup/settings/folders-v2.component.spec.ts b/apps/browser/src/vault/popup/settings/folders-v2.component.spec.ts index d1450667fa8..3cb5503ed89 100644 --- a/apps/browser/src/vault/popup/settings/folders-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/settings/folders-v2.component.spec.ts @@ -21,20 +21,30 @@ import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-heade import { FoldersV2Component } from "./folders-v2.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: "popup-header", template: ``, }) class MockPopupHeaderComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() pageTitle: string = ""; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() backAction: () => void = () => {}; } +// 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: "popup-footer", template: ``, }) class MockPopupFooterComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() pageTitle: string = ""; } diff --git a/apps/browser/src/vault/popup/settings/folders-v2.component.ts b/apps/browser/src/vault/popup/settings/folders-v2.component.ts index b749f651d53..20a816e7297 100644 --- a/apps/browser/src/vault/popup/settings/folders-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/folders-v2.component.ts @@ -22,6 +22,8 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.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({ templateUrl: "./folders-v2.component.html", imports: [ diff --git a/apps/browser/src/vault/popup/settings/more-from-bitwarden-page-v2.component.ts b/apps/browser/src/vault/popup/settings/more-from-bitwarden-page-v2.component.ts index ec7a73a3bc3..2f9fae43da7 100644 --- a/apps/browser/src/vault/popup/settings/more-from-bitwarden-page-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/more-from-bitwarden-page-v2.component.ts @@ -17,6 +17,8 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.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({ templateUrl: "more-from-bitwarden-page-v2.component.html", imports: [ diff --git a/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts b/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts index 1676fea3c01..70ba6842a0d 100644 --- a/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts +++ b/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts @@ -53,9 +53,13 @@ export class TrashListItemsContainerComponent { /** * The list of trashed items to display. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() ciphers: PopupCipherViewLike[] = []; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() headerText: string; diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts index 92cbf951ead..ff6e9b4065c 100644 --- a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts @@ -19,6 +19,8 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.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({ templateUrl: "vault-settings-v2.component.html", imports: [ @@ -37,12 +39,12 @@ export class VaultSettingsV2Component implements OnInit, OnDestroy { private userId$ = this.accountService.activeAccount$.pipe(getUserId); // Check if user is premium user, they will be able to archive items - protected userCanArchive = toSignal( + protected readonly userCanArchive = toSignal( this.userId$.pipe(switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId))), ); // Check if user has archived items (does not check if user is premium) - protected showArchiveFilter = toSignal( + protected readonly showArchiveFilter = toSignal( this.userId$.pipe(switchMap((userId) => this.cipherArchiveService.showArchiveVault$(userId))), ); diff --git a/apps/browser/webpack.base.js b/apps/browser/webpack.base.js index 734a46ac187..4bc2a90c4ff 100644 --- a/apps/browser/webpack.base.js +++ b/apps/browser/webpack.base.js @@ -36,7 +36,8 @@ const DEFAULT_PARAMS = { * outputPath?: string; * mode?: string; * env?: string; - * additionalEntries?: { [outputPath: string]: string } + * additionalEntries?: { [outputPath: string]: string }; + * importAliases?: import("webpack").ResolveOptions["alias"]; * }} params - The input parameters for building the config. */ module.exports.buildConfig = function buildConfig(params) { @@ -362,6 +363,7 @@ module.exports.buildConfig = function buildConfig(params) { path: require.resolve("path-browserify"), }, cache: true, + alias: params.importAliases, }, output: { filename: "[name].js", @@ -482,6 +484,7 @@ module.exports.buildConfig = function buildConfig(params) { path: require.resolve("path-browserify"), }, cache: true, + alias: params.importAliases, }, dependencies: ["main"], plugins: [...requiredPlugins, new AngularCheckPlugin()], diff --git a/apps/cli/webpack.base.js b/apps/cli/webpack.base.js index 01d5fc5b175..532b0a747a0 100644 --- a/apps/cli/webpack.base.js +++ b/apps/cli/webpack.base.js @@ -31,6 +31,7 @@ const DEFAULT_PARAMS = { * localesPath?: string; * externalsModulesDir?: string; * watch?: boolean; + * importAliases?: import("webpack").ResolveOptions["alias"]; * }} params */ module.exports.buildConfig = function buildConfig(params) { @@ -95,6 +96,7 @@ module.exports.buildConfig = function buildConfig(params) { symlinks: false, modules: params.modulesPath, plugins: [new TsconfigPathsPlugin({ configFile: params.tsConfig })], + alias: params.importAliases, }, output: { filename: "[name].js", diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 5dec59f0f12..a0cd1b3dcbf 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -440,33 +440,6 @@ dependencies = [ "tokio-util", ] -[[package]] -name = "bitwarden_chromium_importer" -version = "0.0.0" -dependencies = [ - "aes", - "aes-gcm", - "anyhow", - "async-trait", - "base64", - "cbc", - "hex", - "homedir", - "napi", - "napi-derive", - "oo7", - "pbkdf2", - "rand 0.9.1", - "rusqlite", - "security-framework", - "serde", - "serde_json", - "sha1", - "tokio", - "winapi", - "windows 0.61.1", -] - [[package]] name = "block-buffer" version = "0.10.4" @@ -606,6 +579,31 @@ dependencies = [ "zeroize", ] +[[package]] +name = "chromium_importer" +version = "0.0.0" +dependencies = [ + "aes", + "aes-gcm", + "anyhow", + "async-trait", + "base64", + "cbc", + "hex", + "homedir", + "oo7", + "pbkdf2", + "rand 0.9.1", + "rusqlite", + "security-framework", + "serde", + "serde_json", + "sha1", + "tokio", + "winapi", + "windows 0.61.1", +] + [[package]] name = "cipher" version = "0.4.4" @@ -968,7 +966,7 @@ dependencies = [ "anyhow", "autotype", "base64", - "bitwarden_chromium_importer", + "chromium_importer", "desktop_core", "hex", "napi", @@ -3982,7 +3980,7 @@ dependencies = [ "windows-collections", "windows-core 0.61.0", "windows-future", - "windows-link", + "windows-link 0.1.3", "windows-numerics", ] @@ -4015,9 +4013,9 @@ checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" dependencies = [ "windows-implement 0.60.0", "windows-interface 0.59.1", - "windows-link", + "windows-link 0.1.3", "windows-result 0.3.4", - "windows-strings", + "windows-strings 0.4.2", ] [[package]] @@ -4027,7 +4025,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a1d6bbefcb7b60acd19828e1bc965da6fcf18a7e39490c5f8be71e54a19ba32" dependencies = [ "windows-core 0.61.0", - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -4080,6 +4078,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-numerics" version = "0.2.0" @@ -4087,18 +4091,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ "windows-core 0.61.0", - "windows-link", + "windows-link 0.1.3", ] [[package]] name = "windows-registry" -version = "0.5.3" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link", - "windows-result 0.3.4", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -4116,7 +4120,16 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-link", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", ] [[package]] @@ -4125,7 +4138,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-link", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", ] [[package]] @@ -4216,7 +4238,7 @@ version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ - "windows-link", + "windows-link 0.1.3", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index edf3cb44eca..6a366316328 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -2,7 +2,7 @@ resolver = "2" members = [ "autotype", - "bitwarden_chromium_importer", + "chromium_importer", "core", "macos_provider", "napi", @@ -68,14 +68,14 @@ tokio = "=1.45.0" tokio-stream = "=0.1.15" tokio-util = "=0.7.13" tracing = "=0.1.41" -tracing-subscriber = { version = "=0.3.20", features = ["fmt", "env-filter"] } +tracing-subscriber = { version = "=0.3.20", features = ["fmt", "env-filter", "tracing-log"] } typenum = "=1.18.0" uniffi = "=0.28.3" widestring = "=1.2.0" windows = "=0.61.1" windows-core = "=0.61.0" windows-future = "=0.2.0" -windows-registry = "=0.5.3" +windows-registry = "=0.6.1" zbus = "=5.11.0" zbus_polkit = "=5.0.0" zeroizing-alloc = "=0.1.0" diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/crypto.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/crypto.rs deleted file mode 100644 index e6442e21742..00000000000 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/crypto.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! Cryptographic primitives used in the SDK - -use anyhow::{anyhow, Result}; - -use aes::cipher::{ - block_padding::Pkcs7, generic_array::GenericArray, typenum::U32, BlockDecryptMut, KeyIvInit, -}; - -pub fn decrypt_aes256(iv: &[u8; 16], data: &[u8], key: GenericArray) -> Result> { - let iv = GenericArray::from_slice(iv); - let mut data = data.to_vec(); - cbc::Decryptor::::new(&key, iv) - .decrypt_padded_mut::(&mut data) - .map_err(|_| anyhow!("Failed to decrypt data"))?; - - Ok(data) -} - -#[cfg(test)] -mod tests { - use aes::cipher::{ - generic_array::{sequence::GenericSequence, GenericArray}, - ArrayLength, - }; - use base64::{engine::general_purpose::STANDARD, Engine}; - - pub fn generate_vec(length: usize, offset: u8, increment: u8) -> Vec { - (0..length).map(|i| offset + i as u8 * increment).collect() - } - pub fn generate_generic_array>( - offset: u8, - increment: u8, - ) -> GenericArray { - GenericArray::generate(|i| offset + i as u8 * increment) - } - - #[test] - fn test_decrypt_aes256() { - let iv = generate_vec(16, 0, 1); - let iv: &[u8; 16] = iv.as_slice().try_into().unwrap(); - let key = generate_generic_array(0, 1); - let data: Vec = STANDARD.decode("ByUF8vhyX4ddU9gcooznwA==").unwrap(); - - let decrypted = super::decrypt_aes256(iv, &data, key).unwrap(); - - assert_eq!(String::from_utf8(decrypted).unwrap(), "EncryptMe!\u{6}\u{6}\u{6}\u{6}\u{6}\u{6}"); - } -} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/lib.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/lib.rs deleted file mode 100644 index 84f140d2341..00000000000 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/lib.rs +++ /dev/null @@ -1,8 +0,0 @@ -#[macro_use] -extern crate napi_derive; - -pub mod chromium; -pub mod metadata; -pub mod util; - -pub use crate::chromium::platform::SUPPORTED_BROWSERS as PLATFORM_SUPPORTED_BROWSERS; diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/Cargo.toml b/apps/desktop/desktop_native/chromium_importer/Cargo.toml similarity index 92% rename from apps/desktop/desktop_native/bitwarden_chromium_importer/Cargo.toml rename to apps/desktop/desktop_native/chromium_importer/Cargo.toml index 656c3ad1504..648a36543c2 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/Cargo.toml +++ b/apps/desktop/desktop_native/chromium_importer/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "bitwarden_chromium_importer" +name = "chromium_importer" edition = { workspace = true } license = { workspace = true } version = { workspace = true } @@ -14,8 +14,6 @@ base64 = { workspace = true } cbc = { workspace = true, features = ["alloc"] } hex = { workspace = true } homedir = { workspace = true } -napi = { workspace = true } -napi-derive = { workspace = true } pbkdf2 = "=0.12.2" rand = { workspace = true } rusqlite = { version = "=0.37.0", features = ["bundled"] } @@ -36,4 +34,3 @@ oo7 = { workspace = true } [lints] workspace = true - diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/README.md b/apps/desktop/desktop_native/chromium_importer/README.md similarity index 94% rename from apps/desktop/desktop_native/bitwarden_chromium_importer/README.md rename to apps/desktop/desktop_native/chromium_importer/README.md index 498dd3ac67d..dd563697e5b 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/README.md +++ b/apps/desktop/desktop_native/chromium_importer/README.md @@ -1,6 +1,13 @@ -# Windows ABE Architecture +# Chromium Direct Importer -## Overview +A rust library that allows you to directly import credentials from Chromium-based browsers. + +## Windows ABE Architecture + +On Windows chrome has additional protection measurements which needs to be circumvented in order to +get access to the passwords. + +### Overview The Windows Application Bound Encryption (ABE) consists of three main components that work together: @@ -10,7 +17,7 @@ The Windows Application Bound Encryption (ABE) consists of three main components _(The names of the binaries will be changed for the released product.)_ -## The goal +### The goal The goal of this subsystem is to decrypt the master encryption key with which the login information is encrypted on the local system in Windows. This applies to the most recent versions of Chrome and @@ -24,7 +31,7 @@ Protection API at the system level on top of that. This triply encrypted key is The next paragraphs describe what is done at each level to decrypt the key. -## 1. Client library +### 1. Client library This is a Rust module that is part of the Chromium importer. It only compiles and runs on Windows (see `abe.rs` and `abe_config.rs`). Its main task is to launch `admin.exe` with elevated privileges @@ -52,7 +59,7 @@ admin.exe --service-exe "c:\temp\service.exe" --encrypted "QVBQQgEAAADQjJ3fARXRE **At this point, the user must permit the action to be performed on the UAC screen.** -## 2. Admin executable +### 2. Admin executable This executable receives the full path of `service.exe` and the data to be decrypted. @@ -67,7 +74,7 @@ is sent to the named pipe server created by the user. The user responds with `ok After that, the executable stops and uninstalls the service and then exits. -## 3. System service +### 3. System service The service starts and creates a named pipe server for communication between `admin.exe` and the system service. Please note that it is not possible to communicate between the user and the system @@ -83,7 +90,7 @@ removed from the system. Even though we send only one request, the service is de many clients with as many messages as needed and could be installed on the system permanently if necessary. -## 4. Back to client library +### 4. Back to client library The decrypted base64-encoded string comes back from the admin executable to the named pipe server at the user level. At this point, it has been decrypted only once at the system level. @@ -99,7 +106,7 @@ itself), it's either AES-256-GCM or ChaCha20Poly1305 encryption scheme. The deta After all of these steps, we have the master key which can be used to decrypt the password information stored in the local database. -## Summary +### Summary The Windows ABE decryption process involves a three-tier architecture with named pipe communication: diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/chromium.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/mod.rs similarity index 89% rename from apps/desktop/desktop_native/bitwarden_chromium_importer/src/chromium.rs rename to apps/desktop/desktop_native/chromium_importer/src/chromium/mod.rs index 094500e6d42..55728460436 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/chromium.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/mod.rs @@ -7,11 +7,9 @@ use hex::decode; use homedir::my_home; use rusqlite::{params, Connection}; -// Platform-specific code -#[cfg_attr(target_os = "linux", path = "linux.rs")] -#[cfg_attr(target_os = "windows", path = "windows.rs")] -#[cfg_attr(target_os = "macos", path = "macos.rs")] -pub mod platform; +mod platform; + +pub(crate) use platform::SUPPORTED_BROWSERS as PLATFORM_SUPPORTED_BROWSERS; // // Public API @@ -22,10 +20,7 @@ pub struct ProfileInfo { pub name: String, pub folder: String, - #[allow(dead_code)] pub account_name: Option, - - #[allow(dead_code)] pub account_email: Option, } @@ -113,12 +108,12 @@ pub async fn import_logins( // #[derive(Debug, Clone, Copy)] -pub struct BrowserConfig { +pub(crate) struct BrowserConfig { pub name: &'static str, pub data_dir: &'static str, } -pub static SUPPORTED_BROWSER_MAP: LazyLock< +pub(crate) static SUPPORTED_BROWSER_MAP: LazyLock< std::collections::HashMap<&'static str, &'static BrowserConfig>, > = LazyLock::new(|| { platform::SUPPORTED_BROWSERS @@ -140,12 +135,12 @@ fn get_browser_data_dir(config: &BrowserConfig) -> Result { // #[async_trait] -pub trait CryptoService: Send { +pub(crate) trait CryptoService: Send { async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result; } #[derive(serde::Deserialize, Clone)] -pub struct LocalState { +pub(crate) struct LocalState { profile: AllProfiles, #[allow(dead_code)] os_crypt: Option, @@ -198,16 +193,17 @@ fn load_local_state(browser_dir: &Path) -> Result { } fn get_profile_info(local_state: &LocalState) -> Vec { - let mut profile_infos = Vec::new(); - for (name, info) in local_state.profile.info_cache.iter() { - profile_infos.push(ProfileInfo { + local_state + .profile + .info_cache + .iter() + .map(|(name, info)| ProfileInfo { name: info.name.clone(), folder: name.clone(), account_name: info.gaia_name.clone(), account_email: info.user_name.clone(), - }); - } - profile_infos + }) + .collect() } struct EncryptedLogin { @@ -264,17 +260,16 @@ fn hex_to_bytes(hex: &str) -> Vec { decode(hex).unwrap_or_default() } -fn does_table_exist(conn: &Connection, table_name: &str) -> Result { - let mut stmt = conn.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?1")?; - let exists = stmt.exists(params![table_name])?; - Ok(exists) +fn table_exist(conn: &Connection, table_name: &str) -> Result { + conn.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?1")? + .exists(params![table_name]) } fn query_logins(db_path: &str) -> Result, rusqlite::Error> { let conn = Connection::open(db_path)?; - let have_logins = does_table_exist(&conn, "logins")?; - let have_password_notes = does_table_exist(&conn, "password_notes")?; + let have_logins = table_exist(&conn, "logins")?; + let have_password_notes = table_exist(&conn, "password_notes")?; if !have_logins || !have_password_notes { return Ok(vec![]); } @@ -308,10 +303,7 @@ fn query_logins(db_path: &str) -> Result, rusqlite::Error> { }) })?; - let mut logins = Vec::new(); - for login in logins_iter { - logins.push(login?); - } + let logins = logins_iter.collect::, _>>()?; Ok(logins) } diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/linux.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/linux.rs similarity index 97% rename from apps/desktop/desktop_native/bitwarden_chromium_importer/src/linux.rs rename to apps/desktop/desktop_native/chromium_importer/src/chromium/platform/linux.rs index be3bcdb1e1d..227dffdcca7 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/linux.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/linux.rs @@ -13,7 +13,7 @@ use crate::util; // // TODO: It's possible that there might be multiple possible data directories, depending on the installation method (e.g., snap, flatpak, etc.). -pub const SUPPORTED_BROWSERS: [BrowserConfig; 4] = [ +pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[ BrowserConfig { name: "Chrome", data_dir: ".config/google-chrome", @@ -32,7 +32,7 @@ pub const SUPPORTED_BROWSERS: [BrowserConfig; 4] = [ }, ]; -pub fn get_crypto_service( +pub(crate) fn get_crypto_service( browser_name: &String, _local_state: &LocalState, ) -> Result> { diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/macos.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/macos.rs similarity index 97% rename from apps/desktop/desktop_native/bitwarden_chromium_importer/src/macos.rs rename to apps/desktop/desktop_native/chromium_importer/src/chromium/platform/macos.rs index bcb2c005000..c0e770c161b 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/macos.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/macos.rs @@ -10,7 +10,7 @@ use crate::util; // Public API // -pub const SUPPORTED_BROWSERS: [BrowserConfig; 7] = [ +pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[ BrowserConfig { name: "Chrome", data_dir: "Library/Application Support/Google/Chrome", @@ -41,7 +41,7 @@ pub const SUPPORTED_BROWSERS: [BrowserConfig; 7] = [ }, ]; -pub fn get_crypto_service( +pub(crate) fn get_crypto_service( browser_name: &String, _local_state: &LocalState, ) -> Result> { diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/mod.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/mod.rs new file mode 100644 index 00000000000..2a21ef23d82 --- /dev/null +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/mod.rs @@ -0,0 +1,7 @@ +// Platform-specific code +#[cfg_attr(target_os = "linux", path = "linux.rs")] +#[cfg_attr(target_os = "windows", path = "windows.rs")] +#[cfg_attr(target_os = "macos", path = "macos.rs")] +mod native; + +pub(crate) use native::*; diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/windows.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows.rs similarity index 97% rename from apps/desktop/desktop_native/bitwarden_chromium_importer/src/windows.rs rename to apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows.rs index 096808aafb6..79c462c29a1 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/windows.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows.rs @@ -15,8 +15,7 @@ use crate::util; // Public API // -// IMPORTANT adjust array size when enabling / disabling chromium importers here -pub const SUPPORTED_BROWSERS: [BrowserConfig; 6] = [ +pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[ BrowserConfig { name: "Brave", data_dir: "AppData/Local/BraveSoftware/Brave-Browser/User Data", @@ -43,7 +42,7 @@ pub const SUPPORTED_BROWSERS: [BrowserConfig; 6] = [ }, ]; -pub fn get_crypto_service( +pub(crate) fn get_crypto_service( _browser_name: &str, local_state: &LocalState, ) -> Result> { diff --git a/apps/desktop/desktop_native/chromium_importer/src/lib.rs b/apps/desktop/desktop_native/chromium_importer/src/lib.rs new file mode 100644 index 00000000000..d92515c39f9 --- /dev/null +++ b/apps/desktop/desktop_native/chromium_importer/src/lib.rs @@ -0,0 +1,5 @@ +#![doc = include_str!("../README.md")] + +pub mod chromium; +pub mod metadata; +mod util; diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/metadata.rs b/apps/desktop/desktop_native/chromium_importer/src/metadata.rs similarity index 96% rename from apps/desktop/desktop_native/bitwarden_chromium_importer/src/metadata.rs rename to apps/desktop/desktop_native/chromium_importer/src/metadata.rs index 28f13cd9863..bfd7f184621 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/metadata.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/metadata.rs @@ -1,8 +1,7 @@ use std::collections::{HashMap, HashSet}; -use crate::{chromium::InstalledBrowserRetriever, PLATFORM_SUPPORTED_BROWSERS}; +use crate::chromium::{InstalledBrowserRetriever, PLATFORM_SUPPORTED_BROWSERS}; -#[napi(object)] /// Mechanisms that load data into the importer pub struct NativeImporterMetadata { /// Identifies the importer @@ -24,7 +23,7 @@ pub fn get_supported_importers( // Check for installed browsers let installed_browsers = T::get_installed_browsers().unwrap_or_default(); - const IMPORTERS: [(&str, &str); 6] = [ + const IMPORTERS: &[(&str, &str)] = &[ ("chromecsv", "Chrome"), ("chromiumcsv", "Chromium"), ("bravecsv", "Brave"), @@ -57,9 +56,7 @@ pub fn get_supported_importers( map } -/* - Tests are cfg-gated based upon OS, and must be compiled/run on each OS for full coverage -*/ +// Tests are cfg-gated based upon OS, and must be compiled/run on each OS for full coverage #[cfg(test)] mod tests { use super::*; diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/util.rs b/apps/desktop/desktop_native/chromium_importer/src/util.rs similarity index 77% rename from apps/desktop/desktop_native/bitwarden_chromium_importer/src/util.rs rename to apps/desktop/desktop_native/chromium_importer/src/util.rs index e9c20ab621d..f346d7e6dd0 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/util.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/util.rs @@ -1,9 +1,6 @@ -use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit}; use anyhow::{anyhow, Result}; -use pbkdf2::{hmac::Hmac, pbkdf2}; -use sha1::Sha1; -pub fn split_encrypted_string(encrypted: &[u8]) -> Result<(&str, &[u8])> { +fn split_encrypted_string(encrypted: &[u8]) -> Result<(&str, &[u8])> { if encrypted.len() < 3 { return Err(anyhow!( "Corrupted entry: invalid encrypted string length, expected at least 3 bytes, got {}", @@ -15,7 +12,14 @@ pub fn split_encrypted_string(encrypted: &[u8]) -> Result<(&str, &[u8])> { Ok((std::str::from_utf8(version)?, password)) } -pub fn split_encrypted_string_and_validate<'a>( +/// A Chromium password consists of three parts: +/// - Version (3 bytes): "v10", "v11", etc. +/// - Cipher text (chunks of 16 bytes) +/// - Padding (1-15 bytes) +/// +/// This function splits the encrypted byte slice into version and cipher text. +/// Padding is included and handled by the underlying cryptographic library. +pub(crate) fn split_encrypted_string_and_validate<'a>( encrypted: &'a [u8], supported_versions: &[&str], ) -> Result<(&'a str, &'a [u8])> { @@ -27,15 +31,22 @@ pub fn split_encrypted_string_and_validate<'a>( Ok((version, password)) } -pub fn decrypt_aes_128_cbc(key: &[u8], iv: &[u8], ciphertext: &[u8]) -> Result> { - let decryptor = cbc::Decryptor::::new_from_slices(key, iv)?; - let plaintext: Vec = decryptor +/// Decrypt using AES-128 in CBC mode. +#[cfg(any(target_os = "linux", target_os = "macos", test))] +pub(crate) fn decrypt_aes_128_cbc(key: &[u8], iv: &[u8], ciphertext: &[u8]) -> Result> { + use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit}; + + cbc::Decryptor::::new_from_slices(key, iv)? .decrypt_padded_vec_mut::(ciphertext) - .map_err(|e| anyhow!("Failed to decrypt: {}", e))?; - Ok(plaintext) + .map_err(|e| anyhow!("Failed to decrypt: {}", e)) } -pub fn derive_saltysalt(password: &[u8], iterations: u32) -> Result> { +/// Derives a PBKDF2 key from the static "saltysalt" salt with the given password and iteration count. +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn derive_saltysalt(password: &[u8], iterations: u32) -> Result> { + use pbkdf2::{hmac::Hmac, pbkdf2}; + use sha1::Sha1; + let mut key = vec![0u8; 16]; pbkdf2::>(password, b"saltysalt", iterations, &mut key) .map_err(|e| anyhow!("Failed to derive master key: {}", e))?; @@ -44,16 +55,6 @@ pub fn derive_saltysalt(password: &[u8], iterations: u32) -> Result> { #[cfg(test)] mod tests { - pub fn generate_vec(length: usize, offset: u8, increment: u8) -> Vec { - (0..length).map(|i| offset + i as u8 * increment).collect() - } - pub fn generate_generic_array>( - offset: u8, - increment: u8, - ) -> GenericArray { - GenericArray::generate(|i| offset + i as u8 * increment) - } - use aes::cipher::{ block_padding::Pkcs7, generic_array::{sequence::GenericSequence, GenericArray}, @@ -64,6 +65,17 @@ mod tests { const LENGTH10: usize = 10; const LENGTH0: usize = 0; + fn generate_vec(length: usize, offset: u8, increment: u8) -> Vec { + (0..length).map(|i| offset + i as u8 * increment).collect() + } + + fn generate_generic_array>( + offset: u8, + increment: u8, + ) -> GenericArray { + GenericArray::generate(|i| offset + i as u8 * increment) + } + fn run_split_encrypted_string_test<'a, const N: usize>( successfully_split: bool, plaintext_to_encrypt: &'a str, 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/desktop_native/napi/Cargo.toml b/apps/desktop/desktop_native/napi/Cargo.toml index 5e2e42b463f..4198baa4b5a 100644 --- a/apps/desktop/desktop_native/napi/Cargo.toml +++ b/apps/desktop/desktop_native/napi/Cargo.toml @@ -17,7 +17,7 @@ manual_test = [] anyhow = { workspace = true } autotype = { path = "../autotype" } base64 = { workspace = true } -bitwarden_chromium_importer = { path = "../bitwarden_chromium_importer" } +chromium_importer = { path = "../chromium_importer" } desktop_core = { path = "../core" } hex = { workspace = true } napi = { workspace = true, features = ["async"] } diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index 59751cd3246..0a8beb8c427 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -3,15 +3,6 @@ /* auto-generated by NAPI-RS */ -/** Mechanisms that load data into the importer */ -export interface NativeImporterMetadata { - /** Identifies the importer */ - id: string - /** Describes the strategies used to obtain imported data */ - loaders: Array - /** Identifies the instructions for the importer */ - instructions: string -} export declare namespace passwords { /** The error message returned when a password is not found during retrieval or deletion. */ export const PASSWORD_NOT_FOUND: string @@ -249,9 +240,13 @@ export declare namespace chromium_importer { login?: Login failure?: LoginImportFailure } + export interface NativeImporterMetadata { + id: string + loaders: Array + instructions: string + } /** Returns OS aware metadata describing supported Chromium based importers as a JSON string. */ export function getMetadata(): Record - export function getInstalledBrowsers(): Array export function getAvailableProfiles(browser: string): Array export function importLogins(browser: string, profileId: string): Promise> } diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index a193e44d6df..39e57bd0bb5 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -1051,6 +1051,10 @@ pub mod logging { // overriding the default directive for matching targets. .from_env_lossy(); + // With the `tracing-log` feature enabled for the `tracing_subscriber`, + // the registry below will initialize a log compatibility layer, which allows + // the subscriber to consume log::Records as though they were tracing Events. + // https://docs.rs/tracing-subscriber/latest/tracing_subscriber/util/trait.SubscriberInitExt.html#method.init tracing_subscriber::registry() .with(filter) .with(JsLayer) @@ -1060,11 +1064,13 @@ pub mod logging { #[napi] pub mod chromium_importer { - use bitwarden_chromium_importer::chromium::DefaultInstalledBrowserRetriever; - use bitwarden_chromium_importer::chromium::InstalledBrowserRetriever; - use bitwarden_chromium_importer::chromium::LoginImportResult as _LoginImportResult; - use bitwarden_chromium_importer::chromium::ProfileInfo as _ProfileInfo; - use bitwarden_chromium_importer::metadata::NativeImporterMetadata; + use chromium_importer::{ + chromium::{ + DefaultInstalledBrowserRetriever, LoginImportResult as _LoginImportResult, + ProfileInfo as _ProfileInfo, + }, + metadata::NativeImporterMetadata as _NativeImporterMetadata, + }; use std::collections::HashMap; #[napi(object)] @@ -1094,6 +1100,13 @@ pub mod chromium_importer { pub failure: Option, } + #[napi(object)] + pub struct NativeImporterMetadata { + pub id: String, + pub loaders: Vec<&'static str>, + pub instructions: &'static str, + } + impl From<_LoginImportResult> for LoginImportResult { fn from(l: _LoginImportResult) -> Self { match l { @@ -1127,23 +1140,28 @@ pub mod chromium_importer { } } - #[napi] - /// Returns OS aware metadata describing supported Chromium based importers as a JSON string. - pub fn get_metadata() -> HashMap { - bitwarden_chromium_importer::metadata::get_supported_importers::< - DefaultInstalledBrowserRetriever, - >() + impl From<_NativeImporterMetadata> for NativeImporterMetadata { + fn from(m: _NativeImporterMetadata) -> Self { + NativeImporterMetadata { + id: m.id, + loaders: m.loaders, + instructions: m.instructions, + } + } } #[napi] - pub fn get_installed_browsers() -> napi::Result> { - bitwarden_chromium_importer::chromium::DefaultInstalledBrowserRetriever::get_installed_browsers() - .map_err(|e| napi::Error::from_reason(e.to_string())) + /// Returns OS aware metadata describing supported Chromium based importers as a JSON string. + pub fn get_metadata() -> HashMap { + chromium_importer::metadata::get_supported_importers::() + .into_iter() + .map(|(browser, metadata)| (browser, NativeImporterMetadata::from(metadata))) + .collect() } #[napi] pub fn get_available_profiles(browser: String) -> napi::Result> { - bitwarden_chromium_importer::chromium::get_available_profiles(&browser) + chromium_importer::chromium::get_available_profiles(&browser) .map(|profiles| profiles.into_iter().map(ProfileInfo::from).collect()) .map_err(|e| napi::Error::from_reason(e.to_string())) } @@ -1153,7 +1171,7 @@ pub mod chromium_importer { browser: String, profile_id: String, ) -> napi::Result> { - bitwarden_chromium_importer::chromium::import_logins(&browser, &profile_id) + chromium_importer::chromium::import_logins(&browser, &profile_id) .await .map(|logins| logins.into_iter().map(LoginImportResult::from).collect()) .map_err(|e| napi::Error::from_reason(e.to_string())) diff --git a/apps/desktop/native-messaging-test-runner/package-lock.json b/apps/desktop/native-messaging-test-runner/package-lock.json index 3b976891014..b6e402a3ef6 100644 --- a/apps/desktop/native-messaging-test-runner/package-lock.json +++ b/apps/desktop/native-messaging-test-runner/package-lock.json @@ -19,7 +19,7 @@ "yargs": "18.0.0" }, "devDependencies": { - "@types/node": "22.15.3", + "@types/node": "22.18.11", "typescript": "5.4.2" } }, @@ -117,9 +117,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.15.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.3.tgz", - "integrity": "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==", + "version": "22.18.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.11.tgz", + "integrity": "sha512-Gd33J2XIrXurb+eT2ktze3rJAfAp9ZNjlBdh4SVgyrKEOADwCbdUDaK7QgJno8Ue4kcajscsKqu6n8OBG3hhCQ==", "license": "MIT", "peer": true, "dependencies": { diff --git a/apps/desktop/native-messaging-test-runner/package.json b/apps/desktop/native-messaging-test-runner/package.json index 0ca9cdc3a17..285997f6482 100644 --- a/apps/desktop/native-messaging-test-runner/package.json +++ b/apps/desktop/native-messaging-test-runner/package.json @@ -24,7 +24,7 @@ "yargs": "18.0.0" }, "devDependencies": { - "@types/node": "22.15.3", + "@types/node": "22.18.11", "typescript": "5.4.2" }, "_moduleAliases": { 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-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index a809a1b23a2..b6e86ba19ff 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -2,6 +2,7 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; import { AuthenticationTimeoutComponent } from "@bitwarden/angular/auth/components/authentication-timeout.component"; +import { AuthRoute } from "@bitwarden/angular/auth/constants"; import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/environment-selector/environment-selector.component"; import { authGuard, @@ -65,7 +66,7 @@ const routes: Routes = [ canActivate: [redirectGuard({ loggedIn: "/vault", loggedOut: "/login", locked: "/lock" })], }, { - path: "authentication-timeout", + path: AuthRoute.AuthenticationTimeout, component: AnonLayoutWrapperComponent, children: [ { @@ -81,7 +82,7 @@ const routes: Routes = [ } satisfies RouteDataProperties & AnonLayoutWrapperData, }, { - path: "device-verification", + path: AuthRoute.NewDeviceVerification, component: AnonLayoutWrapperComponent, canActivate: [unauthGuardFn(), activeAuthGuard()], children: [{ path: "", component: NewDeviceVerificationComponent }], @@ -123,7 +124,7 @@ const routes: Routes = [ component: AnonLayoutWrapperComponent, children: [ { - path: "signup", + path: AuthRoute.SignUp, canActivate: [unauthGuardFn()], data: { pageIcon: RegistrationUserAddIcon, @@ -141,13 +142,13 @@ const routes: Routes = [ component: RegistrationStartSecondaryComponent, outlet: "secondary", data: { - loginRoute: "/login", + loginRoute: `/${AuthRoute.Login}`, } satisfies RegistrationStartSecondaryComponentData, }, ], }, { - path: "finish-signup", + path: AuthRoute.FinishSignUp, canActivate: [unauthGuardFn()], data: { pageIcon: LockIcon, @@ -160,7 +161,7 @@ const routes: Routes = [ ], }, { - path: "login", + path: AuthRoute.Login, canActivate: [maxAccountsGuardFn()], data: { pageTitle: { @@ -179,7 +180,7 @@ const routes: Routes = [ ], }, { - path: "login-initiated", + path: AuthRoute.LoginInitiated, canActivate: [tdeDecryptionRequiredGuard()], data: { pageIcon: DevicesIcon, @@ -187,7 +188,7 @@ const routes: Routes = [ children: [{ path: "", component: LoginDecryptionOptionsComponent }], }, { - path: "sso", + path: AuthRoute.Sso, data: { pageIcon: VaultIcon, pageTitle: { @@ -207,7 +208,7 @@ const routes: Routes = [ ], }, { - path: "login-with-device", + path: AuthRoute.LoginWithDevice, data: { pageIcon: DevicesIcon, pageTitle: { @@ -227,7 +228,7 @@ const routes: Routes = [ ], }, { - path: "admin-approval-requested", + path: AuthRoute.AdminApprovalRequested, data: { pageIcon: DevicesIcon, pageTitle: { @@ -240,7 +241,7 @@ const routes: Routes = [ children: [{ path: "", component: LoginViaAuthRequestComponent }], }, { - path: "hint", + path: AuthRoute.PasswordHint, canActivate: [unauthGuardFn()], data: { pageTitle: { @@ -278,7 +279,7 @@ const routes: Routes = [ ], }, { - path: "2fa", + path: AuthRoute.TwoFactor, canActivate: [unauthGuardFn(), TwoFactorAuthGuard], children: [ { @@ -295,7 +296,7 @@ const routes: Routes = [ } satisfies RouteDataProperties & AnonLayoutWrapperData, }, { - path: "set-initial-password", + path: AuthRoute.SetInitialPassword, canActivate: [authGuard], component: SetInitialPasswordComponent, data: { @@ -304,7 +305,7 @@ const routes: Routes = [ } satisfies AnonLayoutWrapperData, }, { - path: "change-password", + path: AuthRoute.ChangePassword, component: ChangePasswordComponent, canActivate: [authGuard], data: { diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 7f7eddcfe95..4b6dcab0dff 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -91,6 +91,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: [], @@ -115,14 +117,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/desktop/src/app/tools/import/chromium-importer.service.ts b/apps/desktop/src/app/tools/import/chromium-importer.service.ts index 5273eef4b54..0faff81974a 100644 --- a/apps/desktop/src/app/tools/import/chromium-importer.service.ts +++ b/apps/desktop/src/app/tools/import/chromium-importer.service.ts @@ -8,10 +8,6 @@ export class ChromiumImporterService { return await chromium_importer.getMetadata(); }); - ipcMain.handle("chromium_importer.getInstalledBrowsers", async (event) => { - return await chromium_importer.getInstalledBrowsers(); - }); - ipcMain.handle("chromium_importer.getAvailableProfiles", async (event, browser: string) => { return await chromium_importer.getAvailableProfiles(browser); }); diff --git a/apps/desktop/src/app/tools/import/desktop-import-metadata.service.ts b/apps/desktop/src/app/tools/import/desktop-import-metadata.service.ts index fc2c2ff1183..0c29cd9f44a 100644 --- a/apps/desktop/src/app/tools/import/desktop-import-metadata.service.ts +++ b/apps/desktop/src/app/tools/import/desktop-import-metadata.service.ts @@ -1,5 +1,5 @@ import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; -import type { NativeImporterMetadata } from "@bitwarden/desktop-napi"; +import type { chromium_importer } from "@bitwarden/desktop-napi"; import { ImportType, DefaultImportMetadataService, @@ -25,7 +25,9 @@ export class DesktopImportMetadataService await super.init(); } - private async parseNativeMetaData(raw: Record): Promise { + private async parseNativeMetaData( + raw: Record, + ): Promise { const entries = Object.entries(raw).map(([id, meta]) => { const loaders = meta.loaders.map(this.mapLoader); const instructions = this.mapInstructions(meta.instructions); diff --git a/apps/desktop/src/app/tools/preload.ts b/apps/desktop/src/app/tools/preload.ts index 4d629c992ad..c21a1ac0bfc 100644 --- a/apps/desktop/src/app/tools/preload.ts +++ b/apps/desktop/src/app/tools/preload.ts @@ -1,12 +1,10 @@ import { ipcRenderer } from "electron"; -import type { NativeImporterMetadata } from "@bitwarden/desktop-napi"; +import type { chromium_importer } from "@bitwarden/desktop-napi"; const chromiumImporter = { - getMetadata: (): Promise> => + getMetadata: (): Promise> => ipcRenderer.invoke("chromium_importer.getMetadata"), - getInstalledBrowsers: (): Promise => - ipcRenderer.invoke("chromium_importer.getInstalledBrowsers"), getAvailableProfiles: (browser: string): Promise => ipcRenderer.invoke("chromium_importer.getAvailableProfiles", browser), importLogins: (browser: string, profileId: string): Promise => diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 19519b9bca1..56f1e97f930 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -4199,5 +4199,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/platform/components/approve-ssh-request.ts b/apps/desktop/src/platform/components/approve-ssh-request.ts index 8cd63e0b1ac..1741124774d 100644 --- a/apps/desktop/src/platform/components/approve-ssh-request.ts +++ b/apps/desktop/src/platform/components/approve-ssh-request.ts @@ -21,6 +21,8 @@ export interface ApproveSshRequestParams { action: 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: "app-approve-ssh-request", templateUrl: "approve-ssh-request.html", diff --git a/apps/desktop/src/vault/app/vault/assign-collections/assign-collections-desktop.component.ts b/apps/desktop/src/vault/app/vault/assign-collections/assign-collections-desktop.component.ts index d81f1662c6c..5af1f96a569 100644 --- a/apps/desktop/src/vault/app/vault/assign-collections/assign-collections-desktop.component.ts +++ b/apps/desktop/src/vault/app/vault/assign-collections/assign-collections-desktop.component.ts @@ -10,6 +10,8 @@ import { CollectionAssignmentResult, } from "@bitwarden/vault"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ standalone: true, templateUrl: "./assign-collections-desktop.component.html", diff --git a/apps/desktop/src/vault/app/vault/credential-generator-dialog.component.ts b/apps/desktop/src/vault/app/vault/credential-generator-dialog.component.ts index 26349920106..775ef55b3eb 100644 --- a/apps/desktop/src/vault/app/vault/credential-generator-dialog.component.ts +++ b/apps/desktop/src/vault/app/vault/credential-generator-dialog.component.ts @@ -39,6 +39,8 @@ export const CredentialGeneratorDialogAction = { type CredentialGeneratorDialogAction = UnionOfValues; +// 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: "credential-generator-dialog", templateUrl: "credential-generator-dialog.component.html", diff --git a/apps/desktop/src/vault/app/vault/item-footer.component.ts b/apps/desktop/src/vault/app/vault/item-footer.component.ts index 5ebd657cee0..0034bd9a43c 100644 --- a/apps/desktop/src/vault/app/vault/item-footer.component.ts +++ b/apps/desktop/src/vault/app/vault/item-footer.component.ts @@ -25,22 +25,46 @@ import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cip import { ButtonComponent, ButtonModule, DialogService, ToastService } from "@bitwarden/components"; import { ArchiveCipherUtilitiesService, PasswordRepromptService } from "@bitwarden/vault"; +// 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-vault-item-footer", templateUrl: "item-footer.component.html", imports: [ButtonModule, CommonModule, JslibModule], }) export class ItemFooterComponent implements OnInit, OnChanges { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) cipher: CipherView = new CipherView(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() collectionId: string | null = null; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) action: string = "view"; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() masterPasswordAlreadyPrompted: boolean = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onEdit = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onClone = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onDelete = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onRestore = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onCancel = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onArchiveToggle = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("submitBtn", { static: false }) submitBtn: ButtonComponent | null = null; activeUserId: UserId | null = null; diff --git a/apps/desktop/src/vault/app/vault/vault-filter/filters/collection-filter.component.ts b/apps/desktop/src/vault/app/vault/vault-filter/filters/collection-filter.component.ts index 22372410e5b..015b301efdb 100644 --- a/apps/desktop/src/vault/app/vault/vault-filter/filters/collection-filter.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-filter/filters/collection-filter.component.ts @@ -2,6 +2,8 @@ import { Component } from "@angular/core"; import { CollectionFilterComponent as BaseCollectionFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/collection-filter.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-collection-filter", templateUrl: "collection-filter.component.html", diff --git a/apps/desktop/src/vault/app/vault/vault-filter/filters/folder-filter.component.ts b/apps/desktop/src/vault/app/vault/vault-filter/filters/folder-filter.component.ts index d7364808f6d..f340e4082b8 100644 --- a/apps/desktop/src/vault/app/vault/vault-filter/filters/folder-filter.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-filter/filters/folder-filter.component.ts @@ -2,6 +2,8 @@ import { Component } from "@angular/core"; import { FolderFilterComponent as BaseFolderFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/folder-filter.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-folder-filter", templateUrl: "folder-filter.component.html", diff --git a/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.ts b/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.ts index 503c2b2ec6e..99338ddbb7c 100644 --- a/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.ts @@ -9,6 +9,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ToastService } from "@bitwarden/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-organization-filter", templateUrl: "organization-filter.component.html", diff --git a/apps/desktop/src/vault/app/vault/vault-filter/filters/status-filter.component.ts b/apps/desktop/src/vault/app/vault/vault-filter/filters/status-filter.component.ts index 276b11d7138..db546f76a2c 100644 --- a/apps/desktop/src/vault/app/vault/vault-filter/filters/status-filter.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-filter/filters/status-filter.component.ts @@ -2,6 +2,8 @@ import { Component } from "@angular/core"; import { StatusFilterComponent as BaseStatusFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/status-filter.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-status-filter", templateUrl: "status-filter.component.html", diff --git a/apps/desktop/src/vault/app/vault/vault-filter/filters/type-filter.component.ts b/apps/desktop/src/vault/app/vault/vault-filter/filters/type-filter.component.ts index 27e7d5c5ecb..fbab7ce4667 100644 --- a/apps/desktop/src/vault/app/vault/vault-filter/filters/type-filter.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-filter/filters/type-filter.component.ts @@ -5,6 +5,8 @@ import { TypeFilterComponent as BaseTypeFilterComponent } from "@bitwarden/angul import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items"; +// 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-type-filter", templateUrl: "type-filter.component.html", diff --git a/apps/desktop/src/vault/app/vault/vault-filter/vault-filter.component.ts b/apps/desktop/src/vault/app/vault/vault-filter/vault-filter.component.ts index 161d22687e8..d7c5bafc3a4 100644 --- a/apps/desktop/src/vault/app/vault/vault-filter/vault-filter.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-filter/vault-filter.component.ts @@ -2,6 +2,8 @@ import { Component } from "@angular/core"; import { VaultFilterComponent as BaseVaultFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/vault-filter.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-vault-filter", templateUrl: "vault-filter.component.html", diff --git a/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts index 290a38ac08c..d312d49277a 100644 --- a/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts @@ -21,6 +21,8 @@ import { MenuModule } from "@bitwarden/components"; import { SearchBarService } from "../../../app/layout/search/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-vault-items-v2", templateUrl: "vault-items-v2.component.html", diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-v2.component.ts index b7b0bf2e1b2..19c9cffeeb2 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts @@ -94,6 +94,8 @@ import { VaultItemsV2Component } from "./vault-items-v2.component"; const BroadcasterSubscriptionId = "VaultComponent"; +// 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-vault", templateUrl: "vault-v2.component.html", @@ -138,12 +140,20 @@ const BroadcasterSubscriptionId = "VaultComponent"; export class VaultV2Component implements OnInit, OnDestroy, CopyClickListener { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(VaultItemsV2Component, { static: true }) vaultItemsComponent: VaultItemsV2Component | null = null; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(VaultFilterComponent, { static: true }) vaultFilterComponent: VaultFilterComponent | null = null; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("folderAddEdit", { read: ViewContainerRef, static: true }) folderAddEditModalRef: ViewContainerRef | null = null; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(CipherFormComponent) cipherFormComponent: CipherFormComponent | null = null; diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 6017d60df5f..6d27e12537a 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -9,6 +9,12 @@ COPY package*.json ./ COPY . . RUN npm ci +# Remove commercial packages if LICENSE_TYPE is not 'commercial' +ARG LICENSE_TYPE=oss +RUN if [ "${LICENSE_TYPE}" != "commercial" ] ; then \ + rm -rf node_modules/@bitwarden/commercial-sdk-internal ; \ + fi + WORKDIR /source/apps/web ARG NPM_COMMAND=dist:bit:selfhost RUN npm run ${NPM_COMMAND} diff --git a/apps/web/config/development.json b/apps/web/config/development.json index 52a0fb0fdf2..6fd5fa49eb2 100644 --- a/apps/web/config/development.json +++ b/apps/web/config/development.json @@ -8,6 +8,7 @@ "proxyIdentity": "http://localhost:33656", "proxyEvents": "http://localhost:46273", "proxyNotifications": "http://localhost:61840", + "proxyIcons": "http://localhost:50024", "wsConnectSrc": "ws://localhost:61840" }, "additionalRegions": [ diff --git a/apps/web/config/selfhosted.json b/apps/web/config/selfhosted.json index cd36ab15c5e..ffb7621e594 100644 --- a/apps/web/config/selfhosted.json +++ b/apps/web/config/selfhosted.json @@ -4,6 +4,7 @@ "proxyIdentity": "http://localhost:33657", "proxyEvents": "http://localhost:46274", "proxyNotifications": "http://localhost:61841", + "proxyKeyConnector": "http://localhost:5000", "port": 8081 }, "flags": {} 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/components/bulk/bulk-confirm-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts index 55385ca0ce9..3a624e11d95 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts @@ -9,6 +9,7 @@ import { OrganizationUserBulkConfirmRequest, OrganizationUserBulkPublicKeyResponse, OrganizationUserBulkResponse, + OrganizationUserService, } from "@bitwarden/admin-console/common"; import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; @@ -26,8 +27,6 @@ import { OrgKey } from "@bitwarden/common/types/key"; import { DIALOG_DATA, DialogConfig, DialogService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; -import { OrganizationUserService } from "../../services/organization-user/organization-user.service"; - import { BaseBulkConfirmComponent } from "./base-bulk-confirm.component"; import { BulkUserDetails } from "./bulk-status.component"; diff --git a/apps/web/src/app/admin-console/organizations/members/services/index.ts b/apps/web/src/app/admin-console/organizations/members/services/index.ts index 2ac2d31cd69..baaa33eeae9 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/index.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/index.ts @@ -2,4 +2,3 @@ export { OrganizationMembersService } from "./organization-members-service/organ export { MemberActionsService } from "./member-actions/member-actions.service"; export { MemberDialogManagerService } from "./member-dialog-manager/member-dialog-manager.service"; export { DeleteManagedMemberWarningService } from "./delete-managed-member/delete-managed-member-warning.service"; -export { OrganizationUserService } from "./organization-user/organization-user.service"; diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts index 6fd7de7b292..e856ab7afd1 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts @@ -10,6 +10,7 @@ import { OrganizationUserStatusType, } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; @@ -20,7 +21,6 @@ import { OrgKey } from "@bitwarden/common/types/key"; import { newGuid } from "@bitwarden/guid"; import { KeyService } from "@bitwarden/key-management"; -import { BillingConstraintService } from "../../../../../billing/members/billing-constraint/billing-constraint.service"; import { OrganizationUserView } from "../../../core/views/organization-user.view"; import { OrganizationUserService } from "../organization-user/organization-user.service"; @@ -34,7 +34,7 @@ describe("MemberActionsService", () => { let encryptService: MockProxy; let configService: MockProxy; let accountService: FakeAccountService; - let billingConstraintService: MockProxy; + let organizationMetadataService: MockProxy; const userId = newGuid() as UserId; const organizationId = newGuid() as OrganizationId; @@ -50,7 +50,7 @@ describe("MemberActionsService", () => { encryptService = mock(); configService = mock(); accountService = mockAccountServiceWith(userId); - billingConstraintService = mock(); + organizationMetadataService = mock(); mockOrganization = { id: organizationId, @@ -75,7 +75,7 @@ describe("MemberActionsService", () => { encryptService, configService, accountService, - billingConstraintService, + organizationMetadataService, ); }); @@ -251,7 +251,7 @@ describe("MemberActionsService", () => { expect(result).toEqual({ success: true }); expect(organizationUserService.confirmUser).toHaveBeenCalledWith( mockOrganization, - mockOrgUser, + mockOrgUser.id, publicKey, ); expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled(); diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts index 3697aba94ff..5e19e26954e 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts @@ -5,6 +5,7 @@ import { OrganizationUserApiService, OrganizationUserBulkResponse, OrganizationUserConfirmRequest, + OrganizationUserService, } from "@bitwarden/admin-console/common"; import { OrganizationUserType, @@ -21,7 +22,6 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { KeyService } from "@bitwarden/key-management"; import { OrganizationUserView } from "../../../core/views/organization-user.view"; -import { OrganizationUserService } from "../organization-user/organization-user.service"; export interface MemberActionResult { success: boolean; @@ -129,7 +129,7 @@ export class MemberActionsService { await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation)) ) { await firstValueFrom( - this.organizationUserService.confirmUser(organization, user, publicKey), + this.organizationUserService.confirmUser(organization, user.id, publicKey), ); } else { const request = await firstValueFrom( 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/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts b/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts index 56e875c101d..55894aafd53 100644 --- a/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts @@ -63,6 +63,8 @@ export type AutoConfirmPolicyDialogData = PolicyEditDialogData & { * Satisfies the PolicyDialogComponent interface structurally * via its static open() function. */ +// 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: "auto-confirm-edit-policy-dialog.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts index 7948bf36af4..7fa4fc2eea7 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts @@ -26,6 +26,8 @@ export class AutoConfirmPolicy extends BasePolicyEditDefinition { } } +// 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: "auto-confirm-policy.component.html", imports: [SharedModule], 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/auth/constants/auth-web-route.constant.ts b/apps/web/src/app/auth/constants/auth-web-route.constant.ts new file mode 100644 index 00000000000..c1e714786e9 --- /dev/null +++ b/apps/web/src/app/auth/constants/auth-web-route.constant.ts @@ -0,0 +1,35 @@ +// Web route segments auth owns under shared infrastructure +export const AuthWebRouteSegment = Object.freeze({ + // settings routes + Account: "account", + EmergencyAccess: "emergency-access", + + // settings/security routes + Password: "password", + TwoFactor: "two-factor", + SecurityKeys: "security-keys", + DeviceManagement: "device-management", +} as const); + +export type AuthWebRouteSegment = (typeof AuthWebRouteSegment)[keyof typeof AuthWebRouteSegment]; + +// Full routes that auth owns in the web app +export const AuthWebRoute = Object.freeze({ + SignUpLinkExpired: "signup-link-expired", + RecoverTwoFactor: "recover-2fa", + AcceptEmergencyAccessInvite: "accept-emergency", + RecoverDeleteAccount: "recover-delete", + VerifyRecoverDeleteAccount: "verify-recover-delete", + AcceptOrganizationInvite: "accept-organization", + + // Composed routes from segments (allowing for router.navigate / routerLink usage) + AccountSettings: `settings/${AuthWebRouteSegment.Account}`, + EmergencyAccessSettings: `settings/${AuthWebRouteSegment.EmergencyAccess}`, + + PasswordSettings: `settings/security/${AuthWebRouteSegment.Password}`, + TwoFactorSettings: `settings/security/${AuthWebRouteSegment.TwoFactor}`, + SecurityKeysSettings: `settings/security/${AuthWebRouteSegment.SecurityKeys}`, + DeviceManagement: `settings/security/${AuthWebRouteSegment.DeviceManagement}`, +} as const); + +export type AuthWebRoute = (typeof AuthWebRoute)[keyof typeof AuthWebRoute]; diff --git a/apps/web/src/app/auth/constants/index.ts b/apps/web/src/app/auth/constants/index.ts new file mode 100644 index 00000000000..3d84e3729de --- /dev/null +++ b/apps/web/src/app/auth/constants/index.ts @@ -0,0 +1 @@ +export * from "./auth-web-route.constant"; diff --git a/apps/web/src/app/auth/organization-invite/accept-organization.component.ts b/apps/web/src/app/auth/organization-invite/accept-organization.component.ts index f98a62f91ea..cb1175a7002 100644 --- a/apps/web/src/app/auth/organization-invite/accept-organization.component.ts +++ b/apps/web/src/app/auth/organization-invite/accept-organization.component.ts @@ -11,6 +11,7 @@ import { OrganizationInvite } from "@bitwarden/common/auth/services/organization import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { ToastService } from "@bitwarden/components"; import { BaseAcceptComponent } from "../../common/base.accept.component"; @@ -35,6 +36,7 @@ export class AcceptOrganizationComponent extends BaseAcceptComponent { private acceptOrganizationInviteService: AcceptOrganizationInviteService, private organizationInviteService: OrganizationInviteService, private accountService: AccountService, + private toastService: ToastService, ) { super(router, platformUtilsService, i18nService, route, authService); } @@ -51,14 +53,13 @@ export class AcceptOrganizationComponent extends BaseAcceptComponent { return; } - this.platformUtilService.showToast( - "success", - this.i18nService.t("inviteAccepted"), - invite.initOrganization + this.toastService.showToast({ + message: invite.initOrganization ? this.i18nService.t("inviteInitAcceptedDesc") - : this.i18nService.t("inviteAcceptedDesc"), - { timeout: 10000 }, - ); + : this.i18nService.t("invitationAcceptedDesc"), + variant: "success", + timeout: 10000, + }); await this.router.navigate(["/vault"]); } 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/premium/premium-vnext.component.ts b/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts index 32c8061b10b..d25e035d1be 100644 --- a/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts +++ b/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts @@ -42,6 +42,13 @@ import { UnifiedUpgradeDialogStep, } from "../upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component"; +const RouteParams = { + callToAction: "callToAction", +} as const; +const RouteParamValues = { + upgradeToPremium: "upgradeToPremium", +} as const; + // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ @@ -61,6 +68,7 @@ export class PremiumVNextComponent { protected hasPremiumFromAnyOrganization$: Observable; protected hasPremiumPersonally$: Observable; protected shouldShowNewDesign$: Observable; + protected shouldShowUpgradeDialogOnInit$: Observable; protected personalPricingTiers$: Observable; protected premiumCardData$: Observable<{ tier: PersonalSubscriptionPricingTier | undefined; @@ -72,7 +80,6 @@ export class PremiumVNextComponent { price: number; features: string[]; }>; - protected subscriber!: BitwardenSubscriber; protected isSelfHost = false; private destroyRef = inject(DestroyRef); @@ -134,6 +141,17 @@ export class PremiumVNextComponent { ) .subscribe(); + this.shouldShowUpgradeDialogOnInit$ = combineLatest([ + this.hasPremiumFromAnyOrganization$, + this.hasPremiumPersonally$, + this.activatedRoute.queryParams, + ]).pipe( + map(([hasOrgPremium, hasPersonalPremium, queryParams]) => { + const cta = queryParams[RouteParams.callToAction]; + return !hasOrgPremium && !hasPersonalPremium && cta === RouteParamValues.upgradeToPremium; + }), + ); + this.personalPricingTiers$ = this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$(); @@ -166,6 +184,17 @@ export class PremiumVNextComponent { }), shareReplay({ bufferSize: 1, refCount: true }), ); + + this.shouldShowUpgradeDialogOnInit$ + .pipe( + switchMap(async (shouldShowUpgradeDialogOnInit) => { + if (shouldShowUpgradeDialogOnInit) { + from(this.openUpgradeDialog("Premium")); + } + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(); } private navigateToSubscriptionPage = (): Promise => diff --git a/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.spec.ts index a9133d220c3..ea74eb67ffc 100644 --- a/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.spec.ts @@ -1,12 +1,14 @@ import { mock, mockReset } from "jest-mock-extended"; -import * as rxjs from "rxjs"; -import { of } from "rxjs"; +import { of, BehaviorSubject } from "rxjs"; import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SyncService } from "@bitwarden/common/platform/sync/sync.service"; import { DialogRef, DialogService } from "@bitwarden/components"; import { @@ -22,8 +24,11 @@ describe("UnifiedUpgradePromptService", () => { const mockConfigService = mock(); const mockBillingService = mock(); const mockVaultProfileService = mock(); + const mockSyncService = mock(); const mockDialogService = mock(); + const mockOrganizationService = mock(); const mockDialogOpen = jest.spyOn(UnifiedUpgradeDialogComponent, "open"); + const mockPlatformUtilsService = mock(); /** * Creates a mock DialogRef that implements the required properties for testing @@ -50,53 +55,90 @@ describe("UnifiedUpgradePromptService", () => { mockConfigService, mockBillingService, mockVaultProfileService, + mockSyncService, mockDialogService, + mockOrganizationService, + mockPlatformUtilsService, ); } const mockAccount: Account = { id: "test-user-id", } as Account; - const accountSubject = new rxjs.BehaviorSubject(mockAccount); + const accountSubject = new BehaviorSubject(mockAccount); describe("initialization", () => { beforeEach(() => { + mockAccountService.activeAccount$ = accountSubject.asObservable(); + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); + mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + setupTestService(); }); it("should be created", () => { expect(sut).toBeTruthy(); }); - - it("should subscribe to account and feature flag observables on construction", () => { - expect(mockConfigService.getFeatureFlag$).toHaveBeenCalledWith( - FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog, - ); - }); }); describe("displayUpgradePromptConditionally", () => { - beforeEach(async () => { + beforeEach(() => { mockAccountService.activeAccount$ = accountSubject.asObservable(); mockDialogOpen.mockReset(); + mockReset(mockDialogService); mockReset(mockConfigService); mockReset(mockBillingService); mockReset(mockVaultProfileService); + mockReset(mockSyncService); + mockReset(mockOrganizationService); + + // Mock sync service methods + mockSyncService.fullSync.mockResolvedValue(true); + mockSyncService.lastSync$.mockReturnValue(of(new Date())); + mockReset(mockPlatformUtilsService); + }); + it("should subscribe to account and feature flag observables when checking display conditions", async () => { + // Arrange + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); + mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); + mockConfigService.getFeatureFlag$.mockReturnValue(of(false)); + mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + + setupTestService(); + + // Act + await sut.displayUpgradePromptConditionally(); + + // Assert + expect(mockConfigService.getFeatureFlag$).toHaveBeenCalledWith( + FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog, + ); + expect(mockAccountService.activeAccount$).toBeDefined(); }); it("should not show dialog when feature flag is disabled", async () => { // Arrange + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); + mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); mockConfigService.getFeatureFlag$.mockReturnValue(of(false)); + mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + const recentDate = new Date(); + recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old + mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate); + setupTestService(); // Act const result = await sut.displayUpgradePromptConditionally(); // Assert expect(result).toBeNull(); + expect(mockDialogOpen).not.toHaveBeenCalled(); }); it("should not show dialog when user has premium", async () => { // Arrange + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(true)); + mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); setupTestService(); // Act @@ -104,15 +146,34 @@ describe("UnifiedUpgradePromptService", () => { // Assert expect(result).toBeNull(); + expect(mockDialogOpen).not.toHaveBeenCalled(); + }); + + it("should not show dialog when user has any organization membership", async () => { + // Arrange + mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + mockOrganizationService.memberOrganizations$.mockReturnValue(of([{ id: "org1" } as any])); + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); + setupTestService(); + + // Act + const result = await sut.displayUpgradePromptConditionally(); + + // Assert + expect(result).toBeNull(); + expect(mockDialogOpen).not.toHaveBeenCalled(); }); it("should not show dialog when profile is older than 5 minutes", async () => { // Arrange mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); const oldDate = new Date(); oldDate.setMinutes(oldDate.getMinutes() - 10); // 10 minutes old mockVaultProfileService.getProfileCreationDate.mockResolvedValue(oldDate); + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); setupTestService(); // Act @@ -120,15 +181,18 @@ describe("UnifiedUpgradePromptService", () => { // Assert expect(result).toBeNull(); + expect(mockDialogOpen).not.toHaveBeenCalled(); }); it("should show dialog when all conditions are met", async () => { //Arrange mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); const recentDate = new Date(); recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate); + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); const expectedResult = { status: UnifiedUpgradeDialogStatus.Closed }; mockDialogOpenMethod(createMockDialogRef(expectedResult)); @@ -153,13 +217,17 @@ describe("UnifiedUpgradePromptService", () => { // Assert expect(result).toBeNull(); + expect(mockDialogOpen).not.toHaveBeenCalled(); }); it("should not show dialog when profile creation date is unavailable", async () => { // Arrange mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); mockVaultProfileService.getProfileCreationDate.mockResolvedValue(null); + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); + setupTestService(); // Act @@ -167,6 +235,26 @@ describe("UnifiedUpgradePromptService", () => { // Assert expect(result).toBeNull(); + expect(mockDialogOpen).not.toHaveBeenCalled(); + }); + + it("should not show dialog when running in self-hosted environment", async () => { + // Arrange + mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); + mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + const recentDate = new Date(); + recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old + mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate); + mockPlatformUtilsService.isSelfHost.mockReturnValue(true); + setupTestService(); + + // Act + const result = await sut.displayUpgradePromptConditionally(); + + // Assert + expect(result).toBeNull(); + expect(mockDialogOpen).not.toHaveBeenCalled(); }); }); }); diff --git a/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.ts b/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.ts index e90f696cfb5..cf5deaf37fa 100644 --- a/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.ts +++ b/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.ts @@ -1,12 +1,16 @@ import { Injectable } from "@angular/core"; -import { combineLatest, firstValueFrom } from "rxjs"; -import { switchMap, take } from "rxjs/operators"; +import { combineLatest, firstValueFrom, timeout, from, Observable, of } from "rxjs"; +import { filter, switchMap, take, map } from "rxjs/operators"; import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SyncService } from "@bitwarden/common/platform/sync/sync.service"; +import { UserId } from "@bitwarden/common/types/guid"; import { DialogRef, DialogService } from "@bitwarden/components"; import { @@ -24,38 +28,40 @@ export class UnifiedUpgradePromptService { private configService: ConfigService, private billingAccountProfileStateService: BillingAccountProfileStateService, private vaultProfileService: VaultProfileService, + private syncService: SyncService, private dialogService: DialogService, + private organizationService: OrganizationService, + private platformUtilsService: PlatformUtilsService, ) {} - private shouldShowPrompt$ = combineLatest([ - this.accountService.activeAccount$, - this.configService.getFeatureFlag$(FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog), - ]).pipe( - switchMap(async ([account, isFlagEnabled]) => { - if (!account || !account?.id) { - return false; - } - // Early return if feature flag is disabled - if (!isFlagEnabled) { - return false; + private shouldShowPrompt$: Observable = this.accountService.activeAccount$.pipe( + switchMap((account) => { + // Check self-hosted first before any other operations + if (this.platformUtilsService.isSelfHost()) { + return of(false); } - // Check if user has premium - const hasPremium = await firstValueFrom( + if (!account) { + return of(false); + } + + const isProfileLessThanFiveMinutesOld = from( + this.isProfileLessThanFiveMinutesOld(account.id), + ); + const hasOrganizations = from(this.hasOrganizations(account.id)); + + return combineLatest([ + isProfileLessThanFiveMinutesOld, + hasOrganizations, this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), + this.configService.getFeatureFlag$(FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog), + ]).pipe( + map(([isProfileLessThanFiveMinutesOld, hasOrganizations, hasPremium, isFlagEnabled]) => { + return ( + isProfileLessThanFiveMinutesOld && !hasOrganizations && !hasPremium && isFlagEnabled + ); + }), ); - - // Early return if user already has premium - if (hasPremium) { - return false; - } - - // Check profile age only if needed - const isProfileLessThanFiveMinutesOld = await this.isProfileLessThanFiveMinutesOld( - account.id, - ); - - return isFlagEnabled && !hasPremium && isProfileLessThanFiveMinutesOld; }), take(1), ); @@ -89,7 +95,7 @@ export class UnifiedUpgradePromptService { const nowInMs = new Date().getTime(); const differenceInMs = nowInMs - createdAtInMs; - const msInAMinute = 1000 * 60; // Milliseconds in a minute for conversion 1 minute = 60 seconds * 1000 ms + const msInAMinute = 1000 * 60; // 60 seconds * 1000ms const differenceInMinutes = Math.round(differenceInMs / msInAMinute); return differenceInMinutes <= 5; @@ -111,4 +117,32 @@ export class UnifiedUpgradePromptService { // Return the result or null if the dialog was dismissed without a result return result || null; } + + /** + * Checks if the user has any organization associated with their account + * @param userId User ID to check + * @returns Promise that resolves to true if user has any organizations, false otherwise + */ + private async hasOrganizations(userId: UserId): Promise { + // Wait for sync to complete to ensure organizations are fully loaded + // Also force a sync to ensure we have the latest data + await this.syncService.fullSync(false); + + // Wait for the sync to complete with timeout to prevent hanging + await firstValueFrom( + this.syncService.lastSync$(userId).pipe( + filter((lastSync) => lastSync !== null), + take(1), + timeout(30000), // 30 second timeout + ), + ); + + // Check if user has any organization membership (any status including pending) + // Try using memberOrganizations$ which might have different filtering logic + const memberOrganizations = await firstValueFrom( + this.organizationService.memberOrganizations$(userId), + ); + + return memberOrganizations.length > 0; + } } 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.html b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html index 9b007ae7a6b..39a80c99458 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html @@ -54,7 +54,7 @@ @if (isFamiliesPlan) {

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 5ad465455f2..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 @@ -6,7 +6,7 @@ import { OnInit, output, signal, - ViewChild, + viewChild, } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl, FormGroup, Validators } from "@angular/forms"; @@ -19,6 +19,8 @@ import { catchError, of, combineLatest, + map, + shareReplay, } from "rxjs"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; @@ -97,6 +99,7 @@ export type UpgradePaymentParams = { templateUrl: "./upgrade-payment.component.html", }) export class UpgradePaymentComponent implements OnInit, AfterViewInit { + private readonly INITIAL_TAX_VALUE = 0; protected readonly selectedPlanId = input.required(); protected readonly account = input.required(); protected goBack = output(); @@ -104,12 +107,8 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { protected selectedPlan: PlanDetails | null = null; protected hasEnoughAccountCredit$!: Observable; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @ViewChild(EnterPaymentMethodComponent) paymentComponent!: EnterPaymentMethodComponent; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @ViewChild(CartSummaryComponent) cartSummaryComponent!: CartSummaryComponent; + readonly paymentComponent = viewChild.required(EnterPaymentMethodComponent); + readonly cartSummaryComponent = viewChild.required(CartSummaryComponent); protected formGroup = new FormGroup({ organizationName: new FormControl("", [Validators.required]), @@ -122,7 +121,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { // Cart Summary data protected passwordManager!: LineItem; - protected estimatedTax = 0; + protected estimatedTax$!: Observable; // Display data protected upgradeToMessage = ""; @@ -162,49 +161,44 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { }; this.upgradeToMessage = this.i18nService.t( - this.isFamiliesPlan ? "upgradeToFamilies" : "upgradeToPremium", + this.isFamiliesPlan ? "startFreeFamiliesTrial" : "upgradeToPremium", ); - - this.estimatedTax = 0; } else { this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null }); return; } }); - this.formGroup.controls.billingAddress.valueChanges - .pipe( - debounceTime(1000), - // Only proceed when form has required values - switchMap(() => this.refreshSalesTax$()), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((tax) => { - this.estimatedTax = tax; - }); - - // Check if user has enough account credit for the purchase - this.hasEnoughAccountCredit$ = combineLatest([ - this.upgradePaymentService.accountCredit$, - this.formGroup.valueChanges.pipe(startWith(this.formGroup.value)), - ]).pipe( - switchMap(([credit, formValue]) => { - const selectedPaymentType = formValue.paymentForm?.type; - if (selectedPaymentType !== NonTokenizablePaymentMethods.accountCredit) { - return of(true); // Not using account credit, so this check doesn't apply - } - - return credit ? of(credit >= this.cartSummaryComponent.total()) : of(false); - }), + this.estimatedTax$ = this.formGroup.controls.billingAddress.valueChanges.pipe( + startWith(this.formGroup.controls.billingAddress.value), + debounceTime(1000), + // Only proceed when form has required values + switchMap(() => this.refreshSalesTax$()), ); this.loading.set(false); } ngAfterViewInit(): void { - if (this.cartSummaryComponent) { - this.cartSummaryComponent.isExpanded.set(false); - } + const cartSummaryComponent = this.cartSummaryComponent(); + cartSummaryComponent.isExpanded.set(false); + + this.hasEnoughAccountCredit$ = combineLatest([ + cartSummaryComponent.total$, + this.upgradePaymentService.accountCredit$, + this.formGroup.controls.paymentForm.valueChanges.pipe( + startWith(this.formGroup.controls.paymentForm.value), + ), + ]).pipe( + map(([total, credit, currentFormValue]) => { + const selectedPaymentType = currentFormValue?.type; + if (selectedPaymentType !== NonTokenizablePaymentMethods.accountCredit) { + return true; // Not using account credit, so this check doesn't apply + } + return credit ? credit >= total : false; + }), + shareReplay({ bufferSize: 1, refCount: true }), // Cache the latest for two async pipes + ); } protected get isPremiumPlan(): boolean { @@ -249,7 +243,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { }; protected isFormValid(): boolean { - return this.formGroup.valid && this.paymentComponent?.validate(); + return this.formGroup.valid && this.paymentComponent().validate(); } private async processUpgrade(): Promise { @@ -332,17 +326,19 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { return { type: NonTokenizablePaymentMethods.accountCredit }; } - return await this.paymentComponent?.tokenize(); + return await this.paymentComponent().tokenize(); } // Create an observable for tax calculation private refreshSalesTax$(): Observable { if (this.formGroup.invalid || !this.selectedPlan) { - return of(0); + return of(this.INITIAL_TAX_VALUE); } const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress); - + if (!billingAddress.country || !billingAddress.postalCode) { + return of(this.INITIAL_TAX_VALUE); + } return from( this.upgradePaymentService.calculateEstimatedTax(this.selectedPlan, billingAddress), ).pipe( @@ -352,7 +348,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { variant: "error", message: this.i18nService.t("taxCalculationError"), }); - return of(0); // Return default value on error + return of(this.INITIAL_TAX_VALUE); // Return default value on error }), ); } diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts index ac415ac4be2..e2a30dd585c 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts @@ -451,9 +451,9 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { "tw-border-solid", "tw-border-primary-600", "hover:tw-border-primary-700", - "focus:tw-border-2", - "focus:tw-border-primary-700", - "focus:tw-rounded-lg", + "tw-border-2", + "!tw-border-primary-700", + "tw-rounded-lg", ]; } case PlanCardState.NotSelected: { diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.ts b/apps/web/src/app/billing/organizations/organization-plans.component.ts index a4ebba7a760..7c081b38279 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.ts @@ -31,6 +31,7 @@ import { ProviderOrganizationCreateRequest } from "@bitwarden/common/admin-conso import { ProviderResponse } from "@bitwarden/common/admin-console/models/response/provider/provider.response"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { assertNonNullish } from "@bitwarden/common/auth/utils"; import { PlanSponsorshipType, PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; import { BillingResponse } from "@bitwarden/common/billing/models/response/billing.response"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; @@ -41,7 +42,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { OrganizationId } from "@bitwarden/common/types/guid"; +import { OrganizationId, ProviderId, UserId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { ToastService } from "@bitwarden/components"; @@ -654,7 +655,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { orgId = this.selfHosted ? await this.createSelfHosted(key, collectionCt, orgKeys) - : await this.createCloudHosted(key, collectionCt, orgKeys, orgKey[1]); + : await this.createCloudHosted(key, collectionCt, orgKeys, orgKey[1], activeUserId); this.toastService.showToast({ variant: "success", @@ -808,6 +809,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { collectionCt: string, orgKeys: [string, EncString], orgKey: SymmetricCryptoKey, + activeUserId: UserId, ): Promise { const request = new OrganizationCreateRequest(); request.key = key; @@ -855,7 +857,14 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { this.formGroup.controls.clientOwnerEmail.value, request, ); - const providerKey = await this.keyService.getProviderKey(this.providerId); + + const providerKey = await firstValueFrom( + this.keyService + .providerKeys$(activeUserId) + .pipe(map((providerKeys) => providerKeys?.[this.providerId as ProviderId] ?? null)), + ); + assertNonNullish(providerKey, "Provider key not found"); + providerRequest.organizationCreateRequest.key = ( await this.encryptService.wrapSymmetricKey(orgKey, providerKey) ).encryptedString; diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html index 5fa10c4c87c..db3dde217c7 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html @@ -223,7 +223,7 @@

{{ "manageSubscription" | i18n }}

{{ "manageSubscriptionFromThe" | i18n }} - {{ + {{ "providerPortal" | i18n }}. diff --git a/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts b/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts index 40785e9b7ea..db95beea7f8 100644 --- a/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts +++ b/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts @@ -70,7 +70,7 @@ type Scenario =

- {{ "zipPostalCode" | i18n }} + {{ "zipPostalCodeLabel" | i18n }}
- {{ "number" | i18n }} + {{ "cardNumberLabel" | i18n }}
@@ -109,7 +109,7 @@ type PaymentMethodFormGroup = FormGroup<{ class="tw-border-none tw-bg-transparent tw-text-primary-600 tw-pr-1" [position]="'above-end'" > - +

{{ "cardSecurityCodeDescription" | i18n }}

@@ -217,7 +217,7 @@ type PaymentMethodFormGroup = FormGroup<{
- {{ "zipPostalCode" | i18n }} + {{ "zipPostalCodeLabel" | i18n }} - ({{ "required" | i18n }}) + ({{ "required" | i18n }})
`, 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/billing/services/stripe.service.ts b/apps/web/src/app/billing/services/stripe.service.ts index 7ea0d7d52c8..f7655ba0c6e 100644 --- a/apps/web/src/app/billing/services/stripe.service.ts +++ b/apps/web/src/app/billing/services/stripe.service.ts @@ -230,6 +230,8 @@ export class StripeService { '"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', fontSize: "16px", fontSmoothing: "antialiased", + lineHeight: "1.5", + padding: "8px 12px", "::placeholder": { color: null, }, 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/breach-report.component.ts b/apps/web/src/app/dirt/reports/pages/breach-report.component.ts index b197c7dcae8..db85f503aec 100644 --- a/apps/web/src/app/dirt/reports/pages/breach-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/breach-report.component.ts @@ -8,6 +8,8 @@ import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BreachAccountResponse } from "@bitwarden/common/dirt/models/response/breach-account.response"; +// 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-breach-report", templateUrl: "breach-report.component.html", diff --git a/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.ts b/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.ts index bf2a528e723..51bdde3eda8 100644 --- a/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.ts @@ -18,6 +18,8 @@ import { CipherReportComponent } from "./cipher-report.component"; type ReportResult = CipherView & { exposedXTimes: number }; +// 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-exposed-passwords-report", templateUrl: "exposed-passwords-report.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 0024af35109..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 @@ -19,6 +19,8 @@ import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/se import { CipherReportComponent } from "./cipher-report.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-inactive-two-factor-report", templateUrl: "inactive-two-factor-report.component.html", @@ -107,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/dirt/reports/pages/organizations/exposed-passwords-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/exposed-passwords-report.component.ts index e7392ad609a..4dbd31ce4dc 100644 --- a/apps/web/src/app/dirt/reports/pages/organizations/exposed-passwords-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/organizations/exposed-passwords-report.component.ts @@ -24,6 +24,8 @@ import { RoutedVaultFilterService } from "../../../../vault/individual-vault/vau import { AdminConsoleCipherFormConfigService } from "../../../../vault/org-vault/services/admin-console-cipher-form-config.service"; import { ExposedPasswordsReportComponent as BaseExposedPasswordsReportComponent } from "../exposed-passwords-report.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-org-exposed-passwords-report", templateUrl: "../exposed-passwords-report.component.html", diff --git a/apps/web/src/app/dirt/reports/pages/organizations/inactive-two-factor-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/inactive-two-factor-report.component.ts index 1105e814245..fde9c35a6de 100644 --- a/apps/web/src/app/dirt/reports/pages/organizations/inactive-two-factor-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/organizations/inactive-two-factor-report.component.ts @@ -23,6 +23,8 @@ import { RoutedVaultFilterService } from "../../../../vault/individual-vault/vau import { AdminConsoleCipherFormConfigService } from "../../../../vault/org-vault/services/admin-console-cipher-form-config.service"; import { InactiveTwoFactorReportComponent as BaseInactiveTwoFactorReportComponent } from "../inactive-two-factor-report.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-inactive-two-factor-report", templateUrl: "../inactive-two-factor-report.component.html", diff --git a/apps/web/src/app/dirt/reports/pages/organizations/reused-passwords-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/reused-passwords-report.component.ts index 5c48919510e..5e457a91bd9 100644 --- a/apps/web/src/app/dirt/reports/pages/organizations/reused-passwords-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/organizations/reused-passwords-report.component.ts @@ -23,6 +23,8 @@ import { RoutedVaultFilterService } from "../../../../vault/individual-vault/vau import { AdminConsoleCipherFormConfigService } from "../../../../vault/org-vault/services/admin-console-cipher-form-config.service"; import { ReusedPasswordsReportComponent as BaseReusedPasswordsReportComponent } from "../reused-passwords-report.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-reused-passwords-report", templateUrl: "../reused-passwords-report.component.html", diff --git a/apps/web/src/app/dirt/reports/pages/organizations/unsecured-websites-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/unsecured-websites-report.component.ts index dad9688f105..24f514d551f 100644 --- a/apps/web/src/app/dirt/reports/pages/organizations/unsecured-websites-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/organizations/unsecured-websites-report.component.ts @@ -23,6 +23,8 @@ import { RoutedVaultFilterService } from "../../../../vault/individual-vault/vau import { AdminConsoleCipherFormConfigService } from "../../../../vault/org-vault/services/admin-console-cipher-form-config.service"; import { UnsecuredWebsitesReportComponent as BaseUnsecuredWebsitesReportComponent } from "../unsecured-websites-report.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-unsecured-websites-report", templateUrl: "../unsecured-websites-report.component.html", diff --git a/apps/web/src/app/dirt/reports/pages/organizations/weak-passwords-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/weak-passwords-report.component.ts index 67ca5081b6b..50c18d1da3b 100644 --- a/apps/web/src/app/dirt/reports/pages/organizations/weak-passwords-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/organizations/weak-passwords-report.component.ts @@ -24,6 +24,8 @@ import { RoutedVaultFilterService } from "../../../../vault/individual-vault/vau import { AdminConsoleCipherFormConfigService } from "../../../../vault/org-vault/services/admin-console-cipher-form-config.service"; import { WeakPasswordsReportComponent as BaseWeakPasswordsReportComponent } from "../weak-passwords-report.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-weak-passwords-report", templateUrl: "../weak-passwords-report.component.html", diff --git a/apps/web/src/app/dirt/reports/pages/reports-home.component.ts b/apps/web/src/app/dirt/reports/pages/reports-home.component.ts index acc3efac58a..a0e3a73aa3f 100644 --- a/apps/web/src/app/dirt/reports/pages/reports-home.component.ts +++ b/apps/web/src/app/dirt/reports/pages/reports-home.component.ts @@ -9,6 +9,8 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { reports, ReportType } from "../reports"; import { ReportEntry, ReportVariant } 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-reports-home", templateUrl: "reports-home.component.html", diff --git a/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.ts b/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.ts index 8e1e4fcf0cc..0a81b19d4ff 100644 --- a/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.ts @@ -17,6 +17,8 @@ import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/se import { CipherReportComponent } from "./cipher-report.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-reused-passwords-report", templateUrl: "reused-passwords-report.component.html", diff --git a/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.ts b/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.ts index 4b9cc3fd789..4a2c0677574 100644 --- a/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.ts @@ -16,6 +16,8 @@ import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/se import { CipherReportComponent } from "./cipher-report.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-unsecured-websites-report", templateUrl: "unsecured-websites-report.component.html", diff --git a/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.ts b/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.ts index 0472dbfaa6f..bb5400346fd 100644 --- a/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.ts @@ -22,6 +22,8 @@ import { CipherReportComponent } from "./cipher-report.component"; type ReportScore = { label: string; badgeVariant: BadgeVariant; sortOrder: number }; type ReportResult = CipherView & { score: number; reportValue: ReportScore; scoreKey: number }; +// 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-weak-passwords-report", templateUrl: "weak-passwords-report.component.html", diff --git a/apps/web/src/app/dirt/reports/reports-layout.component.ts b/apps/web/src/app/dirt/reports/reports-layout.component.ts index 360898e6057..c2fbf858590 100644 --- a/apps/web/src/app/dirt/reports/reports-layout.component.ts +++ b/apps/web/src/app/dirt/reports/reports-layout.component.ts @@ -3,6 +3,8 @@ import { NavigationEnd, Router } from "@angular/router"; import { Subscription } from "rxjs"; import { filter } from "rxjs/operators"; +// 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-reports-layout", templateUrl: "reports-layout.component.html", diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html index 8db0db3b5e6..dab928e6ec3 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html @@ -1,8 +1,8 @@ -
+
-
+

{{ title }}

{{ description }}

-
+ {{ "premium" | i18n }} {{ "upgrade" | i18n }} - +
diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts index e8ffcd01068..565035c2c55 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts @@ -6,16 +6,28 @@ import { Icon } from "@bitwarden/assets/svg"; import { ReportVariant } from "../models/report-variant"; +// 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-report-card", templateUrl: "report-card.component.html", standalone: false, }) export class ReportCardComponent { + // 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() description: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() route: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() icon: Icon; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() variant: ReportVariant; protected get disabled() { diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts b/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts index 76951bf9451..50798fea6e1 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts @@ -4,7 +4,12 @@ import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/an import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { BadgeModule, IconModule } from "@bitwarden/components"; +import { + BadgeModule, + BaseCardComponent, + IconModule, + CardContentComponent, +} from "@bitwarden/components"; import { PreloadedEnglishI18nModule } from "../../../../core/tests"; import { ReportVariant } from "../models/report-variant"; @@ -16,7 +21,15 @@ export default { component: ReportCardComponent, decorators: [ moduleMetadata({ - imports: [JslibModule, BadgeModule, IconModule, RouterTestingModule, PremiumBadgeComponent], + imports: [ + JslibModule, + BadgeModule, + CardContentComponent, + IconModule, + RouterTestingModule, + PremiumBadgeComponent, + BaseCardComponent, + ], }), applicationConfig({ providers: [importProvidersFrom(PreloadedEnglishI18nModule)], diff --git a/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.ts b/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.ts index c81c99d50d5..509e2f3b872 100644 --- a/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.ts +++ b/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.ts @@ -4,11 +4,15 @@ import { Component, Input } from "@angular/core"; import { ReportEntry } from "../models/report-entry"; +// 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-report-list", templateUrl: "report-list.component.html", standalone: false, }) export class ReportListComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() reports: ReportEntry[]; } diff --git a/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts b/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts index 22c7e851bed..5a89eeff803 100644 --- a/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts +++ b/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts @@ -4,7 +4,12 @@ import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/an import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { BadgeModule, IconModule } from "@bitwarden/components"; +import { + BadgeModule, + BaseCardComponent, + CardContentComponent, + IconModule, +} from "@bitwarden/components"; import { PreloadedEnglishI18nModule } from "../../../../core/tests"; import { reports } from "../../reports"; @@ -18,7 +23,15 @@ export default { component: ReportListComponent, decorators: [ moduleMetadata({ - imports: [JslibModule, BadgeModule, RouterTestingModule, IconModule, PremiumBadgeComponent], + imports: [ + JslibModule, + BadgeModule, + RouterTestingModule, + IconModule, + PremiumBadgeComponent, + CardContentComponent, + BaseCardComponent, + ], declarations: [ReportCardComponent], }), applicationConfig({ diff --git a/apps/web/src/app/dirt/reports/shared/reports-shared.module.ts b/apps/web/src/app/dirt/reports/shared/reports-shared.module.ts index cad5d06d798..59e59a6a500 100644 --- a/apps/web/src/app/dirt/reports/shared/reports-shared.module.ts +++ b/apps/web/src/app/dirt/reports/shared/reports-shared.module.ts @@ -1,13 +1,15 @@ import { CommonModule } from "@angular/common"; import { NgModule } from "@angular/core"; +import { BaseCardComponent, CardContentComponent } from "@bitwarden/components"; + import { SharedModule } from "../../../shared/shared.module"; import { ReportCardComponent } from "./report-card/report-card.component"; import { ReportListComponent } from "./report-list/report-list.component"; @NgModule({ - imports: [CommonModule, SharedModule], + imports: [CommonModule, SharedModule, BaseCardComponent, CardContentComponent], declarations: [ReportCardComponent, ReportListComponent], exports: [ReportCardComponent, ReportListComponent], }) diff --git a/apps/web/src/app/layouts/org-switcher/org-switcher.component.html b/apps/web/src/app/layouts/org-switcher/org-switcher.component.html index 96d17e7ada4..a9acddeb0b8 100644 --- a/apps/web/src/app/layouts/org-switcher/org-switcher.component.html +++ b/apps/web/src/app/layouts/org-switcher/org-switcher.component.html @@ -22,7 +22,6 @@ [route]="['../', org.id]" (mainContentClicked)="toggle()" [routerLinkActiveOptions]="{ exact: true }" - (click)="showInactiveSubscriptionDialog(org)" > - await firstValueFrom( - this.organizationWarningsService.showInactiveSubscriptionDialog$(organization), - ); } diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 45ed6dc8eb9..319adb1d8c6 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -2,6 +2,7 @@ import { NgModule } from "@angular/core"; import { Route, RouterModule, Routes } from "@angular/router"; import { AuthenticationTimeoutComponent } from "@bitwarden/angular/auth/components/authentication-timeout.component"; +import { AuthRoute } from "@bitwarden/angular/auth/constants"; import { authGuard, lockGuard, @@ -55,6 +56,7 @@ import { VerifyRecoverDeleteOrgComponent } from "./admin-console/organizations/m import { AcceptFamilySponsorshipComponent } from "./admin-console/organizations/sponsorships/accept-family-sponsorship.component"; import { FamiliesForEnterpriseSetupComponent } from "./admin-console/organizations/sponsorships/families-for-enterprise-setup.component"; import { CreateOrganizationComponent } from "./admin-console/settings/create-organization.component"; +import { AuthWebRoute, AuthWebRouteSegment } from "./auth/constants/auth-web-route.constant"; import { deepLinkGuard } from "./auth/guards/deep-link/deep-link.guard"; import { AcceptOrganizationComponent } from "./auth/organization-invite/accept-organization.component"; import { RecoverDeleteComponent } from "./auth/recover-delete.component"; @@ -93,12 +95,12 @@ const routes: Routes = [ // so that the redirectGuard does not interrupt the navigation. { path: "register", - redirectTo: "signup", + redirectTo: AuthRoute.SignUp, pathMatch: "full", }, { path: "trial", - redirectTo: "signup", + redirectTo: AuthRoute.SignUp, pathMatch: "full", }, { @@ -114,7 +116,7 @@ const routes: Routes = [ }, { path: "verify-email", component: VerifyEmailTokenComponent }, { - path: "accept-organization", + path: AuthWebRoute.AcceptOrganizationInvite, canActivate: [deepLinkGuard()], component: AcceptOrganizationComponent, data: { titleId: "joinOrganization", doNotSaveUrl: false } satisfies RouteDataProperties, @@ -128,7 +130,7 @@ const routes: Routes = [ doNotSaveUrl: false, } satisfies RouteDataProperties, }, - { path: "recover", pathMatch: "full", redirectTo: "recover-2fa" }, + { path: "recover", pathMatch: "full", redirectTo: AuthWebRoute.RecoverTwoFactor }, { path: "verify-recover-delete-org", component: VerifyRecoverDeleteOrgComponent, @@ -142,7 +144,7 @@ const routes: Routes = [ component: AnonLayoutWrapperComponent, children: [ { - path: "login-with-passkey", + path: AuthRoute.LoginWithPasskey, canActivate: [unauthGuardFn()], data: { pageIcon: TwoFactorAuthSecurityKeyIcon, @@ -164,7 +166,7 @@ const routes: Routes = [ ], }, { - path: "signup", + path: AuthRoute.SignUp, canActivate: [unauthGuardFn()], data: { pageIcon: RegistrationUserAddIcon, @@ -189,7 +191,7 @@ const routes: Routes = [ ], }, { - path: "finish-signup", + path: AuthRoute.FinishSignUp, canActivate: [unauthGuardFn()], data: { pageIcon: LockIcon, @@ -203,7 +205,7 @@ const routes: Routes = [ ], }, { - path: "login", + path: AuthRoute.Login, canActivate: [unauthGuardFn()], data: { pageTitle: { @@ -229,7 +231,7 @@ const routes: Routes = [ ], }, { - path: "login-with-device", + path: AuthRoute.LoginWithDevice, data: { pageIcon: DevicesIcon, pageTitle: { @@ -250,7 +252,7 @@ const routes: Routes = [ ], }, { - path: "admin-approval-requested", + path: AuthRoute.AdminApprovalRequested, data: { pageIcon: DevicesIcon, pageTitle: { @@ -264,7 +266,7 @@ const routes: Routes = [ children: [{ path: "", component: LoginViaAuthRequestComponent }], }, { - path: "hint", + path: AuthRoute.PasswordHint, canActivate: [unauthGuardFn()], data: { pageTitle: { @@ -286,7 +288,7 @@ const routes: Routes = [ ], }, { - path: "login-initiated", + path: AuthRoute.LoginInitiated, canActivate: [tdeDecryptionRequiredGuard()], data: { pageIcon: DevicesIcon, @@ -315,7 +317,7 @@ const routes: Routes = [ ], }, { - path: "set-initial-password", + path: AuthRoute.SetInitialPassword, canActivate: [authGuard], component: SetInitialPasswordComponent, data: { @@ -324,7 +326,7 @@ const routes: Routes = [ } satisfies AnonLayoutWrapperData, }, { - path: "signup-link-expired", + path: AuthWebRoute.SignUpLinkExpired, canActivate: [unauthGuardFn()], data: { pageIcon: TwoFactorTimeoutIcon, @@ -337,13 +339,13 @@ const routes: Routes = [ path: "", component: RegistrationLinkExpiredComponent, data: { - loginRoute: "/login", + loginRoute: `/${AuthRoute.Login}`, } satisfies RegistrationStartSecondaryComponentData, }, ], }, { - path: "sso", + path: AuthRoute.Sso, canActivate: [unauthGuardFn()], data: { pageTitle: { @@ -368,7 +370,7 @@ const routes: Routes = [ ], }, { - path: "2fa", + path: AuthRoute.TwoFactor, component: TwoFactorAuthComponent, canActivate: [unauthGuardFn(), TwoFactorAuthGuard], children: [ @@ -408,7 +410,7 @@ const routes: Routes = [ } satisfies AnonLayoutWrapperData, }, { - path: "authentication-timeout", + path: AuthRoute.AuthenticationTimeout, canActivate: [unauthGuardFn()], children: [ { @@ -430,7 +432,7 @@ const routes: Routes = [ } satisfies RouteDataProperties & AnonLayoutWrapperData, }, { - path: "recover-2fa", + path: AuthWebRoute.RecoverTwoFactor, canActivate: [unauthGuardFn()], children: [ { @@ -452,7 +454,7 @@ const routes: Routes = [ } satisfies RouteDataProperties & AnonLayoutWrapperData, }, { - path: "device-verification", + path: AuthRoute.NewDeviceVerification, canActivate: [unauthGuardFn(), activeAuthGuard()], children: [ { @@ -471,7 +473,7 @@ const routes: Routes = [ } satisfies RouteDataProperties & AnonLayoutWrapperData, }, { - path: "accept-emergency", + path: AuthWebRoute.AcceptEmergencyAccessInvite, canActivate: [deepLinkGuard()], data: { pageTitle: { @@ -492,7 +494,7 @@ const routes: Routes = [ ], }, { - path: "recover-delete", + path: AuthWebRoute.RecoverDeleteAccount, canActivate: [unauthGuardFn()], data: { pageTitle: { @@ -514,7 +516,7 @@ const routes: Routes = [ ], }, { - path: "verify-recover-delete", + path: AuthWebRoute.VerifyRecoverDeleteAccount, canActivate: [unauthGuardFn()], data: { pageTitle: { @@ -596,7 +598,7 @@ const routes: Routes = [ ], }, { - path: "change-password", + path: AuthRoute.ChangePassword, component: ChangePasswordComponent, canActivate: [authGuard], data: { @@ -652,9 +654,9 @@ const routes: Routes = [ { path: "settings", children: [ - { path: "", pathMatch: "full", redirectTo: "account" }, + { path: "", pathMatch: "full", redirectTo: AuthWebRouteSegment.Account }, { - path: "account", + path: AuthWebRouteSegment.Account, component: AccountComponent, data: { titleId: "myAccount" } satisfies RouteDataProperties, }, @@ -680,7 +682,7 @@ const routes: Routes = [ ), }, { - path: "emergency-access", + path: AuthWebRouteSegment.EmergencyAccess, children: [ { path: "", 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/shared/components/account-fingerprint/account-fingerprint.component.ts b/apps/web/src/app/shared/components/account-fingerprint/account-fingerprint.component.ts index 256c8d6af34..eb84868dca1 100644 --- a/apps/web/src/app/shared/components/account-fingerprint/account-fingerprint.component.ts +++ b/apps/web/src/app/shared/components/account-fingerprint/account-fingerprint.component.ts @@ -6,14 +6,22 @@ import { KeyService } from "@bitwarden/key-management"; import { SharedModule } from "../../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-account-fingerprint", templateUrl: "account-fingerprint.component.html", imports: [SharedModule], }) export class AccountFingerprintComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() fingerprintMaterial: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() publicKeyBuffer: Uint8Array; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() fingerprintLabel: string; protected fingerprint: string; diff --git a/apps/web/src/app/shared/components/onboarding/onboarding-task.component.ts b/apps/web/src/app/shared/components/onboarding/onboarding-task.component.ts index f9798ec7f0f..277a4d2d26e 100644 --- a/apps/web/src/app/shared/components/onboarding/onboarding-task.component.ts +++ b/apps/web/src/app/shared/components/onboarding/onboarding-task.component.ts @@ -2,6 +2,8 @@ // @ts-strict-ignore 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: "app-onboarding-task", templateUrl: "./onboarding-task.component.html", @@ -11,18 +13,28 @@ import { Component, Input } from "@angular/core"; standalone: false, }) export class OnboardingTaskComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() completed = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() icon = "bwi-info-circle"; + // 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() route: string | any[]; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() isDisabled: boolean = false; diff --git a/apps/web/src/app/shared/components/onboarding/onboarding.component.ts b/apps/web/src/app/shared/components/onboarding/onboarding.component.ts index 5ead9fcc10b..832e7964cce 100644 --- a/apps/web/src/app/shared/components/onboarding/onboarding.component.ts +++ b/apps/web/src/app/shared/components/onboarding/onboarding.component.ts @@ -4,15 +4,23 @@ import { Component, ContentChildren, EventEmitter, Input, Output, QueryList } fr import { OnboardingTaskComponent } from "./onboarding-task.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-onboarding", templateUrl: "./onboarding.component.html", standalone: false, }) export class OnboardingComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ContentChildren(OnboardingTaskComponent) tasks: QueryList; + // 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-output-emitter-ref @Output() dismiss = new EventEmitter(); protected open = true; diff --git a/apps/web/src/app/vault/components/assign-collections/assign-collections-web.component.ts b/apps/web/src/app/vault/components/assign-collections/assign-collections-web.component.ts index 753d2708e60..2b97222fb14 100644 --- a/apps/web/src/app/vault/components/assign-collections/assign-collections-web.component.ts +++ b/apps/web/src/app/vault/components/assign-collections/assign-collections-web.component.ts @@ -12,6 +12,8 @@ import { 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({ imports: [SharedModule, AssignCollectionsComponent, PluralizePipe], templateUrl: "./assign-collections-web.component.html", diff --git a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt-install.component.ts b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt-install.component.ts index 005fbb1b14d..2444ed1f707 100644 --- a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt-install.component.ts +++ b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt-install.component.ts @@ -25,6 +25,8 @@ const WebStoreUrls: Partial> = { "https://microsoftedge.microsoft.com/addons/detail/jbkfoedolllekgbhcbcoahefnbanhhlh", }; +// 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: "vault-browser-extension-prompt-install", templateUrl: "./browser-extension-prompt-install.component.html", diff --git a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts index f3a5b9aa532..cb927d0848c 100644 --- a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts +++ b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts @@ -14,6 +14,8 @@ import { } from "../../services/browser-extension-prompt.service"; import { ManuallyOpenExtensionComponent } from "../manually-open-extension/manually-open-extension.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: "vault-browser-extension-prompt", templateUrl: "./browser-extension-prompt.component.html", diff --git a/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.ts b/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.ts index 646ff76311e..6105aeacf9c 100644 --- a/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.ts +++ b/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.ts @@ -4,6 +4,8 @@ import { BitwardenIcon } from "@bitwarden/assets/svg"; import { IconModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; +// 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: "vault-manually-open-extension", templateUrl: "./manually-open-extension.component.html", diff --git a/apps/web/src/app/vault/components/setup-extension/add-extension-later-dialog.component.ts b/apps/web/src/app/vault/components/setup-extension/add-extension-later-dialog.component.ts index 5f4e3f586f5..9237d70b996 100644 --- a/apps/web/src/app/vault/components/setup-extension/add-extension-later-dialog.component.ts +++ b/apps/web/src/app/vault/components/setup-extension/add-extension-later-dialog.component.ts @@ -16,6 +16,8 @@ export type AddExtensionLaterDialogData = { onDismiss: () => void; }; +// 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: "vault-add-extension-later-dialog", templateUrl: "./add-extension-later-dialog.component.html", diff --git a/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.ts b/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.ts index c9c222e8e64..9a974a395f0 100644 --- a/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.ts +++ b/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.ts @@ -6,12 +6,16 @@ import { debounceTime, fromEvent } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { DarkImageSourceDirective } from "@bitwarden/vault"; +// 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: "vault-add-extension-videos", templateUrl: "./add-extension-videos.component.html", imports: [CommonModule, JslibModule, DarkImageSourceDirective], }) export class AddExtensionVideosComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChildren("video", { read: ElementRef }) protected videoElements!: QueryList< ElementRef >; diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts index 012ac370c70..b5c0d096944 100644 --- a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts +++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts @@ -42,6 +42,8 @@ export const SetupExtensionState = { type SetupExtensionState = UnionOfValues; +// 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: "vault-setup-extension", templateUrl: "./setup-extension.component.html", diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts index b48db2bba91..98922fb114f 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts @@ -129,6 +129,8 @@ export const VaultItemDialogResult = { export type VaultItemDialogResult = UnionOfValues; +// 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-vault-item-dialog", templateUrl: "vault-item-dialog.component.html", @@ -159,9 +161,13 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { * Reference to the dialog content element. Used to scroll to the top of the dialog when switching modes. * @protected */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("dialogContent") protected dialogContent: ElementRef; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(CipherFormComponent) cipherFormComponent!: CipherFormComponent; /** 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) { + + } } - @if (showNavigationLink && !buttonText) { + @if (showActionLink && !buttonText) { diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.ts index c8c73cd0e5a..427e7262f50 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.ts @@ -5,6 +5,8 @@ import { Router } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ButtonModule, ButtonType, LinkModule, TypographyModule } from "@bitwarden/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: "dirt-activity-card", templateUrl: "./activity-card.component.html", @@ -18,59 +20,79 @@ export class ActivityCardComponent { /** * The title of the card goes here */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() title: string = ""; /** * The card metrics text to display next to the value */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() cardMetrics: string = ""; /** * The description text to display below the value and metrics */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() metricDescription: string = ""; /** - * The link to navigate to for more information + * The text to display for the action link */ - @Input() navigationLink: string = ""; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() actionText: string = ""; /** - * The text to display for the navigation link + * Show action link */ - @Input() navigationText: string = ""; - - /** - * Show Navigation link - */ - @Input() showNavigationLink: boolean = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() showActionLink: boolean = false; /** * Icon class to display next to metrics (e.g., "bwi-exclamation-triangle"). * If null, no icon is displayed. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() iconClass: string | null = null; /** * Button text. If provided, a button will be displayed instead of a navigation link. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() buttonText: string = ""; /** * Button type (e.g., "primary", "secondary") */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() buttonType: ButtonType = "primary"; /** * Event emitted when button is clicked */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() buttonClick = new EventEmitter(); - constructor(private router: Router) {} + /** + * Event emitted when action link is clicked + */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref + @Output() actionClick = new EventEmitter(); - navigateToLink = async (navigationLink: string) => { - await this.router.navigateByUrl(navigationLink); - }; + constructor(private router: Router) {} onButtonClick = () => { this.buttonClick.emit(); }; + + onActionClick = () => { + this.actionClick.emit(); + }; } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts index 941d693940b..5c03534720e 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts @@ -191,7 +191,7 @@ export class PasswordChangeMetricComponent implements OnInit { async assignTasks() { await this.accessIntelligenceSecurityTasksService.assignTasks( this.organizationId, - this.allApplicationsDetails, + this.allApplicationsDetails.filter((app) => app.isMarkedAsCritical), ); } } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html index 844b2f92bb3..9fffded215e 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html @@ -13,9 +13,9 @@ [title]="'atRiskMembers' | i18n" [cardMetrics]="'membersAtRiskCount' | i18n: totalCriticalAppsAtRiskMemberCount" [metricDescription]="'membersWithAccessToAtRiskItemsForCriticalApps' | i18n" - navigationText="{{ 'viewAtRiskMembers' | i18n }}" - navigationLink="{{ getLinkForRiskInsightsTab(RiskInsightsTabType.AllApps) }}" - [showNavigationLink]="totalCriticalAppsAtRiskMemberCount > 0" + actionText="{{ 'viewAtRiskMembers' | i18n }}" + [showActionLink]="totalCriticalAppsAtRiskMemberCount > 0" + (actionClick)="onViewAtRiskMembers()" > @@ -35,9 +35,9 @@ : ('criticalApplicationsAreAtRisk' | i18n: totalCriticalAppsAtRiskCount : totalCriticalAppsCount) " - navigationText="{{ 'viewAtRiskApplications' | i18n }}" - navigationLink="{{ getLinkForRiskInsightsTab(RiskInsightsTabType.CriticalApps) }}" - [showNavigationLink]="totalCriticalAppsAtRiskCount > 0" + actionText="{{ 'viewAtRiskApplications' | i18n }}" + [showActionLink]="totalCriticalAppsAtRiskCount > 0" + (actionClick)="onViewAtRiskApplications()" > diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts index 9e3dff3144c..9689110866a 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts @@ -15,13 +15,14 @@ import { getById } from "@bitwarden/common/platform/misc"; import { DialogService } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; -import { RiskInsightsTabType } from "../models/risk-insights.models"; import { ApplicationsLoadingComponent } from "../shared/risk-insights-loading.component"; import { ActivityCardComponent } from "./activity-card.component"; import { PasswordChangeMetricComponent } from "./activity-cards/password-change-metric.component"; import { NewApplicationsDialogComponent } from "./new-applications-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: "dirt-all-activity", imports: [ @@ -80,15 +81,6 @@ export class AllActivityComponent implements OnInit { } } - get RiskInsightsTabType() { - return RiskInsightsTabType; - } - - getLinkForRiskInsightsTab(tabIndex: RiskInsightsTabType): string { - const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId"); - return `/organizations/${organizationId}/access-intelligence/risk-insights?tabIndex=${tabIndex}`; - } - /** * Handles the review new applications button click. * Opens a dialog showing the list of new applications that can be marked as critical. @@ -100,4 +92,20 @@ export class AllActivityComponent implements OnInit { await firstValueFrom(dialogRef.closed); }; + + /** + * Handles the "View at-risk members" link click. + * Opens the at-risk members drawer for critical applications only. + */ + onViewAtRiskMembers = async () => { + await this.dataService.setDrawerForCriticalAtRiskMembers("activityTabAtRiskMembers"); + }; + + /** + * Handles the "View at-risk applications" link click. + * Opens the at-risk applications drawer for critical applications only. + */ + onViewAtRiskApplications = async () => { + await this.dataService.setDrawerForCriticalAtRiskApps("activityTabAtRiskApplications"); + }; } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.ts index e06d889c59e..05b47da40ed 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.ts @@ -15,6 +15,8 @@ export interface NewApplicationsDialogData { newApplications: 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: "./new-applications-dialog.component.html", imports: [CommonModule, ButtonModule, DialogModule, TypographyModule, I18nPipe], diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts index 57ee0b20360..5fbc841778a 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts @@ -28,6 +28,8 @@ import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pip import { AppTableRowScrollableComponent } from "../shared/app-table-row-scrollable.component"; import { ApplicationsLoadingComponent } from "../shared/risk-insights-loading.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: "dirt-all-applications", templateUrl: "./all-applications.component.html", diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts index dffc493e51d..e297f8eda3c 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts @@ -28,6 +28,8 @@ import { RiskInsightsTabType } from "../models/risk-insights.models"; import { AppTableRowScrollableComponent } from "../shared/app-table-row-scrollable.component"; import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks.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: "dirt-critical-applications", templateUrl: "./critical-applications.component.html", diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts index e1264b009b8..8e58ba22454 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts @@ -28,6 +28,8 @@ import { AllApplicationsComponent } from "./all-applications/all-applications.co import { CriticalApplicationsComponent } from "./critical-applications/critical-applications.component"; import { RiskInsightsTabType } from "./models/risk-insights.models"; +// 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: "./risk-insights.component.html", imports: [ diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.ts index e34b13176ee..f2ecff75847 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.ts @@ -7,19 +7,37 @@ import { MenuModule, TableDataSource, TableModule } from "@bitwarden/components" import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.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-table-row-scrollable", imports: [CommonModule, JslibModule, TableModule, SharedModule, PipesModule, MenuModule], templateUrl: "./app-table-row-scrollable.component.html", }) export class AppTableRowScrollableComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() dataSource!: TableDataSource; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() showRowMenuForCriticalApps: boolean = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() showRowCheckBox: boolean = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() selectedUrls: Set = new Set(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() openApplication: string = ""; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() showAppAtRiskMembers!: (applicationName: string) => void; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() unmarkAsCritical!: (applicationName: string) => void; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() checkboxChange!: (applicationName: string, $event: Event) => void; } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-loading.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-loading.component.ts index 1d18ca3a030..d9cd8878b75 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-loading.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-loading.component.ts @@ -3,6 +3,8 @@ import { Component } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.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: "dirt-risk-insights-loading", imports: [CommonModule, JslibModule], diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.html index 423b0130385..19a12755ca0 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.html @@ -1,5 +1,5 @@ -
@@ -27,8 +27,8 @@ }
-
-

+ +

{{ name }} @if (showConnectedBadge()) { @@ -41,8 +41,9 @@ }

-

{{ description }}

- + @if (description) { +

{{ description }}

+ } @if (canSetupConnection) {

-
+ + diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts index 3a243f8eb91..e6d4aff05fb 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts @@ -20,7 +20,13 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { ThemeType } from "@bitwarden/common/platform/enums"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; -import { DialogRef, DialogService, ToastService } from "@bitwarden/components"; +import { + BaseCardComponent, + CardContentComponent, + DialogRef, + DialogService, + ToastService, +} from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { @@ -32,22 +38,38 @@ import { openHecConnectDialog, } from "../integration-dialog/index"; +// 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-integration-card", templateUrl: "./integration-card.component.html", - imports: [SharedModule], + imports: [SharedModule, BaseCardComponent, CardContentComponent], }) export class IntegrationCardComponent implements AfterViewInit, OnDestroy { private destroyed$: Subject = new Subject(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("imageEle") imageEle!: ElementRef; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() name: string = ""; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() image: string = ""; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() imageDarkMode: string = ""; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() linkURL: string = ""; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() integrationSettings!: Integration; /** Adds relevant `rel` attribute to external links */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() externalURL?: boolean; /** @@ -56,8 +78,14 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { * * @example "2024-12-31" */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() newBadgeExpiration?: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() description?: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() canSetupConnection?: boolean; organizationId: OrganizationId; diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.ts index d186910d2f7..47760c6311a 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.ts @@ -27,6 +27,8 @@ export const DatadogConnectDialogResultStatus = { export type DatadogConnectDialogResultStatusType = (typeof DatadogConnectDialogResultStatus)[keyof typeof DatadogConnectDialogResultStatus]; +// 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: "./connect-dialog-datadog.component.html", imports: [SharedModule], diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.ts index dc3490843cf..3612f2c76cb 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.ts @@ -28,6 +28,8 @@ export const HecConnectDialogResultStatus = { export type HecConnectDialogResultStatusType = (typeof HecConnectDialogResultStatus)[keyof typeof HecConnectDialogResultStatus]; +// 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: "./connect-dialog-hec.component.html", imports: [SharedModule], diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.ts index 66ccc2530c2..19f15d1caea 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.ts @@ -6,15 +6,23 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { IntegrationCardComponent } from "../integration-card/integration-card.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-integration-grid", templateUrl: "./integration-grid.component.html", imports: [IntegrationCardComponent, SharedModule], }) export class IntegrationGridComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() integrations: Integration[] = []; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() ariaI18nKey: string = "integrationCardAriaLabel"; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() tooltipI18nKey: string = "integrationCardTooltip"; protected IntegrationType = IntegrationType; diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts index f0292ef90e7..f19fa6178bf 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts @@ -21,6 +21,8 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { IntegrationGridComponent } from "./integration-grid/integration-grid.component"; import { FilterIntegrationsPipe } from "./integrations.pipe"; +// 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: "ac-integrations", templateUrl: "./integrations.component.html", diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.ts index 796cf212a67..445cee6683c 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.ts @@ -8,12 +8,15 @@ import { BehaviorSubject, debounceTime, firstValueFrom, lastValueFrom } from "rx import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { DialogService, SearchModule, TableDataSource } from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; import { ExportHelper } from "@bitwarden/vault-export-core"; import { CoreOrganizationModule } from "@bitwarden/web-vault/app/admin-console/organizations/core"; import { @@ -31,6 +34,8 @@ import { MemberAccessReportService } from "./services/member-access-report.servi import { userReportItemHeaders } from "./view/member-access-export.view"; import { MemberAccessReportView } from "./view/member-access-report.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: "member-access-report", templateUrl: "member-access-report.component.html", @@ -39,7 +44,7 @@ import { MemberAccessReportView } from "./view/member-access-report.view"; safeProvider({ provide: MemberAccessReportServiceAbstraction, useClass: MemberAccessReportService, - deps: [MemberAccessReportApiService, I18nService], + deps: [MemberAccessReportApiService, I18nService, EncryptService, KeyService, AccountService], }), ], }) diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.spec.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.spec.ts index ad388cfed04..615e6d079b2 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.spec.ts @@ -1,7 +1,13 @@ import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { OrganizationId } from "@bitwarden/common/types/guid"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { mockAccountServiceWith } from "@bitwarden/common/spec"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { newGuid } from "@bitwarden/guid"; +import { KeyService } from "@bitwarden/key-management"; import { MemberAccessReportApiService } from "./member-access-report-api.service"; import { @@ -9,9 +15,14 @@ import { memberAccessWithoutAccessDetailsReportsMock, } from "./member-access-report.mock"; import { MemberAccessReportService } from "./member-access-report.service"; + describe("ImportService", () => { const mockOrganizationId = "mockOrgId" as OrganizationId; const reportApiService = mock(); + const mockEncryptService = mock(); + const userId = newGuid() as UserId; + const mockAccountService = mockAccountServiceWith(userId); + const mockKeyService = mock(); let memberAccessReportService: MemberAccessReportService; const i18nMock = mock({ t(key) { @@ -20,10 +31,19 @@ describe("ImportService", () => { }); beforeEach(() => { + mockKeyService.orgKeys$.mockReturnValue( + of({ mockOrgId: new SymmetricCryptoKey(new Uint8Array(64)) }), + ); reportApiService.getMemberAccessData.mockImplementation(() => Promise.resolve(memberAccessReportsMock), ); - memberAccessReportService = new MemberAccessReportService(reportApiService, i18nMock); + memberAccessReportService = new MemberAccessReportService( + reportApiService, + i18nMock, + mockEncryptService, + mockKeyService, + mockAccountService, + ); }); describe("generateMemberAccessReportView", () => { diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts index caa27a75b82..f6d1139f619 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts @@ -1,11 +1,16 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Injectable } from "@angular/core"; +import { firstValueFrom, map } from "rxjs"; import { CollectionAccessSelectionView } from "@bitwarden/admin-console/common"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Guid, OrganizationId } from "@bitwarden/common/types/guid"; +import { KeyService } from "@bitwarden/key-management"; import { getPermissionList, convertToPermission, @@ -22,6 +27,9 @@ export class MemberAccessReportService { constructor( private reportApiService: MemberAccessReportApiService, private i18nService: I18nService, + private encryptService: EncryptService, + private keyService: KeyService, + private accountService: AccountService, ) {} /** * Transforms user data into a MemberAccessReportView. @@ -78,14 +86,22 @@ export class MemberAccessReportService { async generateUserReportExportItems( organizationId: OrganizationId, ): Promise { + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const organizationSymmetricKey = await firstValueFrom( + this.keyService.orgKeys$(activeUserId).pipe(map((keys) => keys[organizationId])), + ); + const memberAccessReports = await this.reportApiService.getMemberAccessData(organizationId); const collectionNames = memberAccessReports.map((item) => item.collectionName.encryptedString); const collectionNameMap = new Map(collectionNames.map((col) => [col, ""])); for await (const key of collectionNameMap.keys()) { - const decrypted = new EncString(key); - await decrypted.decrypt(organizationId); - collectionNameMap.set(key, decrypted.decryptedValue); + const encryptedCollectionName = new EncString(key); + const collectionName = await this.encryptService.decryptString( + encryptedCollectionName, + organizationSymmetricKey, + ); + collectionNameMap.set(key, collectionName); } const exportItems = memberAccessReports.map((report) => { diff --git a/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.html b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.html new file mode 100644 index 00000000000..2b718990c30 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.html @@ -0,0 +1,38 @@ + +
+ +

+ {{ "sessionTimeoutConfirmationNeverTitle" | i18n }} +

+
+ + +

{{ "sessionTimeoutConfirmationNeverDescription" | i18n }}

+ + {{ "learnMoreAboutDeviceProtection" | i18n }} + + +
+ +
+ + +
+
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 43d512439f0..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: "
", @@ -74,7 +78,13 @@ describe("IntegrationsComponent", () => { (integrationList.componentInstance as IntegrationGridComponent).integrations.map( (i) => i.name, ), - ).toEqual(["GitHub Actions", "GitLab CI/CD", "Ansible", "Kubernetes Operator"]); + ).toEqual([ + "GitHub Actions", + "GitLab CI/CD", + "Ansible", + "Kubernetes Operator", + "Terraform Provider", + ]); expect( (sdkList.componentInstance as IntegrationGridComponent).integrations.map((i) => i.name), 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 31aff308c51..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", @@ -36,7 +38,7 @@ export class IntegrationsComponent { }, { name: "Ansible", - linkURL: "https://bitwarden.com/help/ansible-integration/", + linkURL: "https://galaxy.ansible.com/ui/repo/published/bitwarden/secrets", image: "../../../../../../../images/secrets-manager/integrations/ansible.svg", type: IntegrationType.Integration, }, @@ -96,6 +98,13 @@ export class IntegrationsComponent { type: IntegrationType.Integration, newBadgeExpiration: "2024-8-12", }, + { + name: "Terraform Provider", + linkURL: "https://registry.terraform.io/providers/bitwarden/bitwarden-secrets/latest", + image: "../../../../../../../images/secrets-manager/integrations/terraform.svg", + type: IntegrationType.Integration, + newBadgeExpiration: "2025-12-12", // December 12, 2025 + }, ]; } 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/bitwarden_license/bit-web/webpack.config.js b/bitwarden_license/bit-web/webpack.config.js index 6ac1efdc192..6433eee59f6 100644 --- a/bitwarden_license/bit-web/webpack.config.js +++ b/bitwarden_license/bit-web/webpack.config.js @@ -17,6 +17,12 @@ module.exports = (webpackConfig, context) => { context.context && context.context.root ? path.resolve(context.context.root, context.options.outputPath) : context.options.outputPath, + importAliases: [ + { + name: "@bitwarden/sdk-internal", + alias: "@bitwarden/commercial-sdk-internal", + }, + ], }); } else { return buildConfig({ @@ -26,6 +32,12 @@ module.exports = (webpackConfig, context) => { entryModule: "bitwarden_license/bit-web/src/app/app.module#AppModule", }, tsConfig: path.resolve(__dirname, "tsconfig.build.json"), + importAliases: [ + { + name: "@bitwarden/sdk-internal", + alias: "@bitwarden/commercial-sdk-internal", + }, + ], }); } }; 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/admin-console/src/common/auto-confirm/abstractions/auto-confirm.service.abstraction.ts b/libs/admin-console/src/common/auto-confirm/abstractions/auto-confirm.service.abstraction.ts new file mode 100644 index 00000000000..e753184273e --- /dev/null +++ b/libs/admin-console/src/common/auto-confirm/abstractions/auto-confirm.service.abstraction.ts @@ -0,0 +1,42 @@ +import { Observable } from "rxjs"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { UserId } from "@bitwarden/user-core"; + +import { AutoConfirmState } from "../models/auto-confirm-state.model"; + +export abstract class AutomaticUserConfirmationService { + /** + * @param userId + * @returns Observable an observable with the Auto Confirm user state for the provided userId. + **/ + abstract configuration$(userId: UserId): Observable; + /** + * Upserts the existing user state with a new configuration. + * @param userId + * @param config The new AutoConfirmState to upsert into the user state for the provided userId. + **/ + abstract upsert(userId: UserId, config: AutoConfirmState): Promise; + /** + * This will check if the feature is enabled, the organization plan feature UseAutomaticUserConfirmation is enabled + * and the the provided user has admin/owner/manage custom permission role. + * @param userId + * @returns Observable an observable with a boolean telling us if the provided user may confgure the auto confirm feature. + **/ + abstract canManageAutoConfirm$( + userId: UserId, + organizationId: OrganizationId, + ): Observable; + /** + * Calls the API endpoint to initiate automatic user confirmation. + * @param userId The userId of the logged in admin performing auto confirmation. This is neccesary to perform the key exchange and for permissions checks. + * @param confirmingUserId The userId of the user being confirmed. + * @param organization the organization the user is being auto confirmed to. + **/ + abstract autoConfirmUser( + userId: UserId, + confirmingUserId: UserId, + organization: Organization, + ): Promise; +} diff --git a/libs/admin-console/src/common/auto-confirm/abstractions/index.ts b/libs/admin-console/src/common/auto-confirm/abstractions/index.ts new file mode 100644 index 00000000000..87e284656ab --- /dev/null +++ b/libs/admin-console/src/common/auto-confirm/abstractions/index.ts @@ -0,0 +1 @@ +export * from "./auto-confirm.service.abstraction"; diff --git a/libs/admin-console/src/common/auto-confirm/index.ts b/libs/admin-console/src/common/auto-confirm/index.ts new file mode 100644 index 00000000000..9187ccd39cf --- /dev/null +++ b/libs/admin-console/src/common/auto-confirm/index.ts @@ -0,0 +1,3 @@ +export * from "./abstractions"; +export * from "./models"; +export * from "./services"; diff --git a/libs/common/src/admin-console/services/auto-confirm/auto-confirm.state.ts b/libs/admin-console/src/common/auto-confirm/models/auto-confirm-state.model.ts similarity index 84% rename from libs/common/src/admin-console/services/auto-confirm/auto-confirm.state.ts rename to libs/admin-console/src/common/auto-confirm/models/auto-confirm-state.model.ts index b97f980b644..c69db69746c 100644 --- a/libs/common/src/admin-console/services/auto-confirm/auto-confirm.state.ts +++ b/libs/admin-console/src/common/auto-confirm/models/auto-confirm-state.model.ts @@ -1,4 +1,4 @@ -import { AUTO_CONFIRM, UserKeyDefinition } from "../../../platform/state"; +import { AUTO_CONFIRM, UserKeyDefinition } from "@bitwarden/state"; export class AutoConfirmState { enabled: boolean; diff --git a/libs/admin-console/src/common/auto-confirm/models/index.ts b/libs/admin-console/src/common/auto-confirm/models/index.ts new file mode 100644 index 00000000000..a34c54c16aa --- /dev/null +++ b/libs/admin-console/src/common/auto-confirm/models/index.ts @@ -0,0 +1 @@ +export * from "./auto-confirm-state.model"; diff --git a/libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.spec.ts b/libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.spec.ts new file mode 100644 index 00000000000..133dac758b4 --- /dev/null +++ b/libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.spec.ts @@ -0,0 +1,382 @@ +import { TestBed } from "@angular/core/testing"; +import { BehaviorSubject, firstValueFrom, of, throwError } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api"; +import { OrganizationData } from "@bitwarden/common/admin-console/models/data/organization.data"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; + +import { + DefaultOrganizationUserService, + OrganizationUserApiService, + OrganizationUserConfirmRequest, +} from "../../organization-user"; +import { AUTO_CONFIRM_STATE, AutoConfirmState } from "../models/auto-confirm-state.model"; + +import { DefaultAutomaticUserConfirmationService } from "./default-auto-confirm.service"; + +describe("DefaultAutomaticUserConfirmationService", () => { + let service: DefaultAutomaticUserConfirmationService; + let configService: jest.Mocked; + let apiService: jest.Mocked; + let organizationUserService: jest.Mocked; + let stateProvider: FakeStateProvider; + let organizationService: jest.Mocked; + let organizationUserApiService: jest.Mocked; + + const mockUserId = Utils.newGuid() as UserId; + const mockConfirmingUserId = Utils.newGuid() as UserId; + const mockOrganizationId = Utils.newGuid() as OrganizationId; + let mockOrganization: Organization; + + beforeEach(() => { + configService = { + getFeatureFlag$: jest.fn(), + } as any; + + apiService = { + getUserPublicKey: jest.fn(), + } as any; + + organizationUserService = { + buildConfirmRequest: jest.fn(), + } as any; + + stateProvider = new FakeStateProvider(mockAccountServiceWith(mockUserId)); + + organizationService = { + organizations$: jest.fn(), + } as any; + + organizationUserApiService = { + postOrganizationUserConfirm: jest.fn(), + } as any; + + TestBed.configureTestingModule({ + providers: [ + DefaultAutomaticUserConfirmationService, + { provide: ConfigService, useValue: configService }, + { provide: ApiService, useValue: apiService }, + { provide: DefaultOrganizationUserService, useValue: organizationUserService }, + { provide: "StateProvider", useValue: stateProvider }, + { + provide: InternalOrganizationServiceAbstraction, + useValue: organizationService, + }, + { provide: OrganizationUserApiService, useValue: organizationUserApiService }, + ], + }); + + service = new DefaultAutomaticUserConfirmationService( + configService, + apiService, + organizationUserService, + stateProvider, + organizationService, + organizationUserApiService, + ); + + const mockOrgData = new OrganizationData({} as any, {} as any); + mockOrgData.id = mockOrganizationId; + mockOrgData.useAutomaticUserConfirmation = true; + + const permissions = new PermissionsApi(); + permissions.manageUsers = true; + mockOrgData.permissions = permissions; + + mockOrganization = new Organization(mockOrgData); + }); + + describe("configuration$", () => { + it("should return default AutoConfirmState when no state exists", async () => { + const config$ = service.configuration$(mockUserId); + const config = await firstValueFrom(config$); + + expect(config).toBeInstanceOf(AutoConfirmState); + expect(config.enabled).toBe(false); + expect(config.showSetupDialog).toBe(true); + }); + + it("should return stored AutoConfirmState when state exists", async () => { + const expectedConfig = new AutoConfirmState(); + expectedConfig.enabled = true; + expectedConfig.showSetupDialog = false; + expectedConfig.showBrowserNotification = true; + + await stateProvider.setUserState( + AUTO_CONFIRM_STATE, + { [mockUserId]: expectedConfig }, + mockUserId, + ); + + const config$ = service.configuration$(mockUserId); + const config = await firstValueFrom(config$); + + expect(config.enabled).toBe(true); + expect(config.showSetupDialog).toBe(false); + expect(config.showBrowserNotification).toBe(true); + }); + + it("should emit updates when state changes", async () => { + const config$ = service.configuration$(mockUserId); + const configs: AutoConfirmState[] = []; + + const subscription = config$.subscribe((config) => configs.push(config)); + + expect(configs[0].enabled).toBe(false); + + const newConfig = new AutoConfirmState(); + newConfig.enabled = true; + await stateProvider.setUserState(AUTO_CONFIRM_STATE, { [mockUserId]: newConfig }, mockUserId); + + expect(configs.length).toBeGreaterThan(1); + expect(configs[configs.length - 1].enabled).toBe(true); + + subscription.unsubscribe(); + }); + }); + + describe("upsert", () => { + it("should store new configuration for user", async () => { + const newConfig = new AutoConfirmState(); + newConfig.enabled = true; + newConfig.showSetupDialog = false; + + await service.upsert(mockUserId, newConfig); + + const storedState = await firstValueFrom( + stateProvider.getUser(mockUserId, AUTO_CONFIRM_STATE).state$, + ); + + expect(storedState != null); + expect(storedState![mockUserId]).toEqual(newConfig); + }); + + it("should update existing configuration for user", async () => { + const initialConfig = new AutoConfirmState(); + initialConfig.enabled = false; + + await service.upsert(mockUserId, initialConfig); + + const updatedConfig = new AutoConfirmState(); + updatedConfig.enabled = true; + updatedConfig.showSetupDialog = false; + + await service.upsert(mockUserId, updatedConfig); + + const storedState = await firstValueFrom( + stateProvider.getUser(mockUserId, AUTO_CONFIRM_STATE).state$, + ); + + expect(storedState != null); + expect(storedState![mockUserId].enabled).toBe(true); + expect(storedState![mockUserId].showSetupDialog).toBe(false); + }); + + it("should preserve other user configurations when updating", async () => { + const otherUserId = Utils.newGuid() as UserId; + const otherConfig = new AutoConfirmState(); + otherConfig.enabled = true; + + await stateProvider.setUserState( + AUTO_CONFIRM_STATE, + { [otherUserId]: otherConfig }, + mockUserId, + ); + + const newConfig = new AutoConfirmState(); + newConfig.enabled = false; + + await service.upsert(mockUserId, newConfig); + + const storedState = await firstValueFrom( + stateProvider.getUser(mockUserId, AUTO_CONFIRM_STATE).state$, + ); + + expect(storedState != null); + expect(storedState![mockUserId]).toEqual(newConfig); + expect(storedState![otherUserId]).toEqual(otherConfig); + }); + }); + + describe("canManageAutoConfirm$", () => { + beforeEach(() => { + const organizations$ = new BehaviorSubject([mockOrganization]); + organizationService.organizations$.mockReturnValue(organizations$); + }); + + it("should return true when feature flag is enabled and organization allows management", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + + const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + const canManage = await firstValueFrom(canManage$); + + expect(canManage).toBe(true); + }); + + it("should return false when feature flag is disabled", async () => { + configService.getFeatureFlag$.mockReturnValue(of(false)); + + const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + const canManage = await firstValueFrom(canManage$); + + expect(canManage).toBe(false); + }); + + it("should return false when organization canManageUsers is false", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + + // Create organization without manageUsers permission + const mockOrgData = new OrganizationData({} as any, {} as any); + mockOrgData.id = mockOrganizationId; + mockOrgData.useAutomaticUserConfirmation = true; + const permissions = new PermissionsApi(); + permissions.manageUsers = false; + mockOrgData.permissions = permissions; + const orgWithoutManageUsers = new Organization(mockOrgData); + + const organizations$ = new BehaviorSubject([orgWithoutManageUsers]); + organizationService.organizations$.mockReturnValue(organizations$); + + const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + const canManage = await firstValueFrom(canManage$); + + expect(canManage).toBe(false); + }); + + it("should return false when organization useAutomaticUserConfirmation is false", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + + // Create organization without useAutomaticUserConfirmation + const mockOrgData = new OrganizationData({} as any, {} as any); + mockOrgData.id = mockOrganizationId; + mockOrgData.useAutomaticUserConfirmation = false; + const permissions = new PermissionsApi(); + permissions.manageUsers = true; + mockOrgData.permissions = permissions; + const orgWithoutAutoConfirm = new Organization(mockOrgData); + + const organizations$ = new BehaviorSubject([orgWithoutAutoConfirm]); + organizationService.organizations$.mockReturnValue(organizations$); + + const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + const canManage = await firstValueFrom(canManage$); + + expect(canManage).toBe(false); + }); + + it("should return false when organization is not found", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + + const organizations$ = new BehaviorSubject([]); + organizationService.organizations$.mockReturnValue(organizations$); + + const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + const canManage = await firstValueFrom(canManage$); + + expect(canManage).toBe(false); + }); + + it("should use the correct feature flag", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + + const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + await firstValueFrom(canManage$); + + expect(configService.getFeatureFlag$).toHaveBeenCalledWith(FeatureFlag.AutoConfirm); + }); + }); + + describe("autoConfirmUser", () => { + const mockPublicKey = "mock-public-key-base64"; + const mockPublicKeyArray = new Uint8Array([1, 2, 3, 4]); + const mockConfirmRequest = { + key: "encrypted-key", + defaultUserCollectionName: "encrypted-collection", + } as OrganizationUserConfirmRequest; + + beforeEach(() => { + const organizations$ = new BehaviorSubject([mockOrganization]); + organizationService.organizations$.mockReturnValue(organizations$); + configService.getFeatureFlag$.mockReturnValue(of(true)); + + apiService.getUserPublicKey.mockResolvedValue({ publicKey: mockPublicKey } as any); + jest.spyOn(Utils, "fromB64ToArray").mockReturnValue(mockPublicKeyArray); + organizationUserService.buildConfirmRequest.mockReturnValue(of(mockConfirmRequest)); + organizationUserApiService.postOrganizationUserConfirm.mockResolvedValue(undefined); + }); + + it("should successfully auto-confirm a user", async () => { + await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization); + + expect(apiService.getUserPublicKey).toHaveBeenCalledWith(mockUserId); + expect(organizationUserService.buildConfirmRequest).toHaveBeenCalledWith( + mockOrganization, + mockPublicKeyArray, + ); + expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith( + mockOrganizationId, + mockConfirmingUserId, + mockConfirmRequest, + ); + }); + + it("should not confirm user when canManageAutoConfirm returns false", async () => { + configService.getFeatureFlag$.mockReturnValue(of(false)); + + await expect( + service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization), + ).rejects.toThrow("Cannot automatically confirm user (insufficient permissions)"); + + expect(apiService.getUserPublicKey).not.toHaveBeenCalled(); + expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled(); + }); + + it("should build confirm request with organization and public key", async () => { + await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization); + + expect(organizationUserService.buildConfirmRequest).toHaveBeenCalledWith( + mockOrganization, + mockPublicKeyArray, + ); + }); + + it("should call API with correct parameters", async () => { + await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization); + + expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith( + mockOrganization.id, + mockConfirmingUserId, + mockConfirmRequest, + ); + }); + + it("should handle API errors gracefully", async () => { + const apiError = new Error("API Error"); + apiService.getUserPublicKey.mockRejectedValue(apiError); + + await expect( + service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization), + ).rejects.toThrow("API Error"); + + expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled(); + }); + + it("should handle buildConfirmRequest errors gracefully", async () => { + const buildError = new Error("Build Error"); + organizationUserService.buildConfirmRequest.mockReturnValue(throwError(() => buildError)); + + await expect( + service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization), + ).rejects.toThrow("Build Error"); + + expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.ts b/libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.ts new file mode 100644 index 00000000000..d6c435b84a3 --- /dev/null +++ b/libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.ts @@ -0,0 +1,87 @@ +import { combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { getById } from "@bitwarden/common/platform/misc"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { StateProvider } from "@bitwarden/state"; +import { UserId } from "@bitwarden/user-core"; + +import { OrganizationUserApiService, OrganizationUserService } from "../../organization-user"; +import { AutomaticUserConfirmationService } from "../abstractions/auto-confirm.service.abstraction"; +import { AUTO_CONFIRM_STATE, AutoConfirmState } from "../models/auto-confirm-state.model"; + +export class DefaultAutomaticUserConfirmationService implements AutomaticUserConfirmationService { + constructor( + private configService: ConfigService, + private apiService: ApiService, + private organizationUserService: OrganizationUserService, + private stateProvider: StateProvider, + private organizationService: InternalOrganizationServiceAbstraction, + private organizationUserApiService: OrganizationUserApiService, + ) {} + private autoConfirmState(userId: UserId) { + return this.stateProvider.getUser(userId, AUTO_CONFIRM_STATE); + } + + configuration$(userId: UserId): Observable { + return this.autoConfirmState(userId).state$.pipe( + map((records) => records?.[userId] ?? new AutoConfirmState()), + ); + } + + async upsert(userId: UserId, config: AutoConfirmState): Promise { + await this.autoConfirmState(userId).update((records) => { + return { + ...records, + [userId]: config, + }; + }); + } + + canManageAutoConfirm$(userId: UserId, organizationId: OrganizationId): Observable { + return combineLatest([ + this.configService.getFeatureFlag$(FeatureFlag.AutoConfirm), + this.organizationService.organizations$(userId).pipe(getById(organizationId)), + ]).pipe( + map( + ([enabled, organization]) => + (enabled && organization?.canManageUsers && organization?.useAutomaticUserConfirmation) ?? + false, + ), + ); + } + + async autoConfirmUser( + userId: UserId, + confirmingUserId: UserId, + organization: Organization, + ): Promise { + await firstValueFrom( + this.canManageAutoConfirm$(userId, organization.id).pipe( + map((canManage) => { + if (!canManage) { + throw new Error("Cannot automatically confirm user (insufficient permissions)"); + } + return canManage; + }), + switchMap(() => this.apiService.getUserPublicKey(userId)), + map((publicKeyResponse) => Utils.fromB64ToArray(publicKeyResponse.publicKey)), + switchMap((publicKey) => + this.organizationUserService.buildConfirmRequest(organization, publicKey), + ), + switchMap((request) => + this.organizationUserApiService.postOrganizationUserConfirm( + organization.id, + confirmingUserId, + request, + ), + ), + ), + ); + } +} diff --git a/libs/admin-console/src/common/auto-confirm/services/index.ts b/libs/admin-console/src/common/auto-confirm/services/index.ts new file mode 100644 index 00000000000..305ae380848 --- /dev/null +++ b/libs/admin-console/src/common/auto-confirm/services/index.ts @@ -0,0 +1 @@ +export * from "./default-auto-confirm.service"; diff --git a/libs/admin-console/src/common/index.ts b/libs/admin-console/src/common/index.ts index edeff5aa314..37f79d56256 100644 --- a/libs/admin-console/src/common/index.ts +++ b/libs/admin-console/src/common/index.ts @@ -1,2 +1,3 @@ -export * from "./organization-user"; +export * from "./auto-confirm"; export * from "./collections"; +export * from "./organization-user"; diff --git a/libs/admin-console/src/common/organization-user/abstractions/index.ts b/libs/admin-console/src/common/organization-user/abstractions/index.ts index 01cd189b3dd..dc2788deead 100644 --- a/libs/admin-console/src/common/organization-user/abstractions/index.ts +++ b/libs/admin-console/src/common/organization-user/abstractions/index.ts @@ -1 +1,2 @@ export * from "./organization-user-api.service"; +export * from "./organization-user.service"; diff --git a/libs/admin-console/src/common/organization-user/abstractions/organization-user-api.service.ts b/libs/admin-console/src/common/organization-user/abstractions/organization-user-api.service.ts index ff422231a12..71d228ff822 100644 --- a/libs/admin-console/src/common/organization-user/abstractions/organization-user-api.service.ts +++ b/libs/admin-console/src/common/organization-user/abstractions/organization-user-api.service.ts @@ -148,6 +148,19 @@ export abstract class OrganizationUserApiService { request: OrganizationUserConfirmRequest, ): Promise; + /** + * Admin api for automatically confirming an organization user that + * has accepted their invitation + * @param organizationId - Identifier for the organization to confirm + * @param id - Organization user identifier + * @param request - Request details for confirming the user + */ + abstract postOrganizationUserAutoConfirm( + organizationId: string, + id: string, + request: OrganizationUserConfirmRequest, + ): Promise; + /** * Retrieve a list of the specified users' public keys * @param organizationId - Identifier for the organization to accept diff --git a/libs/admin-console/src/common/organization-user/abstractions/organization-user.service.ts b/libs/admin-console/src/common/organization-user/abstractions/organization-user.service.ts new file mode 100644 index 00000000000..844a0f412be --- /dev/null +++ b/libs/admin-console/src/common/organization-user/abstractions/organization-user.service.ts @@ -0,0 +1,45 @@ +import { Observable } from "rxjs"; + +import { + OrganizationUserConfirmRequest, + OrganizationUserBulkResponse, +} from "@bitwarden/admin-console/common"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; + +export abstract class OrganizationUserService { + /** + * Builds a confirmation request for an organization user. + * @param organization - The organization the user belongs to + * @param publicKey - The user's public key + * @returns An observable that emits the confirmation request + */ + abstract buildConfirmRequest( + organization: Organization, + publicKey: Uint8Array, + ): Observable; + + /** + * Confirms a user in an organization. + * @param organization - The organization the user belongs to + * @param userId - The ID of the user to confirm + * @param publicKey - The user's public key + * @returns An observable that completes when the user is confirmed + */ + abstract confirmUser( + organization: Organization, + userId: string, + publicKey: Uint8Array, + ): Observable; + + /** + * Confirms multiple users in an organization. + * @param organization - The organization the users belong to + * @param userIdsWithKeys - Array of user IDs with their encrypted keys + * @returns An observable that emits the bulk confirmation response + */ + abstract bulkConfirmUsers( + organization: Organization, + userIdsWithKeys: { id: string; key: string }[], + ): Observable>; +} diff --git a/libs/admin-console/src/common/organization-user/services/default-organization-user-api.service.ts b/libs/admin-console/src/common/organization-user/services/default-organization-user-api.service.ts index c16fba258ec..869d84a8c8e 100644 --- a/libs/admin-console/src/common/organization-user/services/default-organization-user-api.service.ts +++ b/libs/admin-console/src/common/organization-user/services/default-organization-user-api.service.ts @@ -194,6 +194,20 @@ export class DefaultOrganizationUserApiService implements OrganizationUserApiSer ); } + postOrganizationUserAutoConfirm( + organizationId: string, + id: string, + request: OrganizationUserConfirmRequest, + ): Promise { + return this.apiService.send( + "POST", + "/organizations/" + organizationId + "/users/" + id + "/auto-confirm", + request, + true, + false, + ); + } + async postOrganizationUsersPublicKey( organizationId: string, ids: string[], diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-user/organization-user.service.spec.ts b/libs/admin-console/src/common/organization-user/services/default-organization-user.service.spec.ts similarity index 91% rename from apps/web/src/app/admin-console/organizations/members/services/organization-user/organization-user.service.spec.ts rename to libs/admin-console/src/common/organization-user/services/default-organization-user.service.spec.ts index 2ae5aa4eb98..982fb3ca5e0 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/organization-user/organization-user.service.spec.ts +++ b/libs/admin-console/src/common/organization-user/services/default-organization-user.service.spec.ts @@ -19,12 +19,10 @@ import { OrganizationId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; import { KeyService } from "@bitwarden/key-management"; -import { OrganizationUserView } from "../../../core/views/organization-user.view"; +import { DefaultOrganizationUserService } from "./default-organization-user.service"; -import { OrganizationUserService } from "./organization-user.service"; - -describe("OrganizationUserService", () => { - let service: OrganizationUserService; +describe("DefaultOrganizationUserService", () => { + let service: DefaultOrganizationUserService; let keyService: jest.Mocked; let encryptService: jest.Mocked; let organizationUserApiService: jest.Mocked; @@ -34,9 +32,7 @@ describe("OrganizationUserService", () => { const mockOrganization = new Organization(); mockOrganization.id = "org-123" as OrganizationId; - const mockOrganizationUser = new OrganizationUserView(); - mockOrganizationUser.id = "user-123"; - + const mockUserId = "user-123"; const mockPublicKey = new Uint8Array(64) as CsprngArray; const mockRandomBytes = new Uint8Array(64) as CsprngArray; const mockOrgKey = new SymmetricCryptoKey(mockRandomBytes) as OrgKey; @@ -77,7 +73,7 @@ describe("OrganizationUserService", () => { TestBed.configureTestingModule({ providers: [ - OrganizationUserService, + DefaultOrganizationUserService, { provide: KeyService, useValue: keyService }, { provide: EncryptService, useValue: encryptService }, { provide: OrganizationUserApiService, useValue: organizationUserApiService }, @@ -86,7 +82,13 @@ describe("OrganizationUserService", () => { ], }); - service = TestBed.inject(OrganizationUserService); + service = new DefaultOrganizationUserService( + keyService, + encryptService, + organizationUserApiService, + accountService, + i18nService, + ); }); describe("confirmUser", () => { @@ -97,7 +99,7 @@ describe("OrganizationUserService", () => { }); it("should confirm a user successfully", (done) => { - service.confirmUser(mockOrganization, mockOrganizationUser, mockPublicKey).subscribe({ + service.confirmUser(mockOrganization, mockUserId, mockPublicKey).subscribe({ next: () => { expect(i18nService.t).toHaveBeenCalledWith("myItems"); @@ -112,7 +114,7 @@ describe("OrganizationUserService", () => { expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith( mockOrganization.id, - mockOrganizationUser.id, + mockUserId, { key: mockEncryptedKey.encryptedString, defaultUserCollectionName: mockEncryptedCollectionName.encryptedString, diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-user/organization-user.service.ts b/libs/admin-console/src/common/organization-user/services/default-organization-user.service.ts similarity index 80% rename from apps/web/src/app/admin-console/organizations/members/services/organization-user/organization-user.service.ts rename to libs/admin-console/src/common/organization-user/services/default-organization-user.service.ts index f59b377e26e..4f503a92675 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/organization-user/organization-user.service.ts +++ b/libs/admin-console/src/common/organization-user/services/default-organization-user.service.ts @@ -1,4 +1,3 @@ -import { Injectable } from "@angular/core"; import { combineLatest, filter, map, Observable, switchMap } from "rxjs"; import { @@ -6,6 +5,7 @@ import { OrganizationUserBulkConfirmRequest, OrganizationUserApiService, OrganizationUserBulkResponse, + OrganizationUserService, } from "@bitwarden/admin-console/common"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -16,12 +16,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { OrganizationId } from "@bitwarden/common/types/guid"; import { KeyService } from "@bitwarden/key-management"; -import { OrganizationUserView } from "../../../core/views/organization-user.view"; - -@Injectable({ - providedIn: "root", -}) -export class OrganizationUserService { +export class DefaultOrganizationUserService implements OrganizationUserService { constructor( protected keyService: KeyService, private encryptService: EncryptService, @@ -39,11 +34,10 @@ export class OrganizationUserService { ); } - confirmUser( + buildConfirmRequest( organization: Organization, - user: OrganizationUserView, publicKey: Uint8Array, - ): Observable { + ): Observable { const encryptedCollectionName$ = this.getEncryptedDefaultCollectionName$(organization); const encryptedKey$ = this.orgKey$(organization).pipe( @@ -51,18 +45,22 @@ export class OrganizationUserService { ); return combineLatest([encryptedKey$, encryptedCollectionName$]).pipe( - switchMap(([key, collectionName]) => { - const request: OrganizationUserConfirmRequest = { - key: key.encryptedString, - defaultUserCollectionName: collectionName.encryptedString, - }; + map(([key, collectionName]) => ({ + key: key.encryptedString, + defaultUserCollectionName: collectionName.encryptedString, + })), + ); + } - return this.organizationUserApiService.postOrganizationUserConfirm( + confirmUser(organization: Organization, userId: string, publicKey: Uint8Array): Observable { + return this.buildConfirmRequest(organization, publicKey).pipe( + switchMap((request) => + this.organizationUserApiService.postOrganizationUserConfirm( organization.id, - user.id, + userId, request, - ); - }), + ), + ), ); } diff --git a/libs/admin-console/src/common/organization-user/services/index.ts b/libs/admin-console/src/common/organization-user/services/index.ts index 6135236d6a6..929a9fcd39a 100644 --- a/libs/admin-console/src/common/organization-user/services/index.ts +++ b/libs/admin-console/src/common/organization-user/services/index.ts @@ -1 +1,2 @@ export * from "./default-organization-user-api.service"; +export * from "./default-organization-user.service"; diff --git a/libs/angular/src/auth/constants/auth-route.constant.ts b/libs/angular/src/auth/constants/auth-route.constant.ts new file mode 100644 index 00000000000..caacfbbc4a8 --- /dev/null +++ b/libs/angular/src/auth/constants/auth-route.constant.ts @@ -0,0 +1,21 @@ +/** + * Constants for auth team owned full routes which are shared across clients. + */ +export const AuthRoute = Object.freeze({ + SignUp: "signup", + FinishSignUp: "finish-signup", + Login: "login", + LoginWithDevice: "login-with-device", + AdminApprovalRequested: "admin-approval-requested", + PasswordHint: "hint", + LoginInitiated: "login-initiated", + SetInitialPassword: "set-initial-password", + ChangePassword: "change-password", + Sso: "sso", + TwoFactor: "2fa", + AuthenticationTimeout: "authentication-timeout", + NewDeviceVerification: "device-verification", + LoginWithPasskey: "login-with-passkey", +} as const); + +export type AuthRoute = (typeof AuthRoute)[keyof typeof AuthRoute]; diff --git a/libs/angular/src/auth/constants/index.ts b/libs/angular/src/auth/constants/index.ts new file mode 100644 index 00000000000..d8e362734c1 --- /dev/null +++ b/libs/angular/src/auth/constants/index.ts @@ -0,0 +1 @@ +export * from "./auth-route.constant"; 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/angular/src/platform/guard/feature-flag.guard.spec.ts b/libs/angular/src/platform/guard/feature-flag.guard.spec.ts index 3bc8b085a7d..fa6d82b49e0 100644 --- a/libs/angular/src/platform/guard/feature-flag.guard.spec.ts +++ b/libs/angular/src/platform/guard/feature-flag.guard.spec.ts @@ -12,6 +12,8 @@ import { I18nMockService, ToastService } from "@bitwarden/components/src"; import { canAccessFeature } from "./feature-flag.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 EmptyComponent {} diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 29864b00481..d20ad58393a 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -10,7 +10,9 @@ import { CollectionService, DefaultCollectionService, DefaultOrganizationUserApiService, + DefaultOrganizationUserService, OrganizationUserApiService, + OrganizationUserService, } from "@bitwarden/admin-console/common"; import { ChangePasswordService, @@ -1144,6 +1146,17 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultOrganizationService, deps: [StateProvider], }), + safeProvider({ + provide: OrganizationUserService, + useClass: DefaultOrganizationUserService, + deps: [ + KeyService, + EncryptService, + OrganizationUserApiService, + AccountService, + I18nServiceAbstraction, + ], + }), safeProvider({ provide: OrganizationServiceAbstraction, useExisting: InternalOrganizationServiceAbstraction, diff --git a/libs/angular/src/vault/components/folder-add-edit.component.ts b/libs/angular/src/vault/components/folder-add-edit.component.ts index acf7511284d..486585b810c 100644 --- a/libs/angular/src/vault/components/folder-add-edit.component.ts +++ b/libs/angular/src/vault/components/folder-add-edit.component.ts @@ -16,8 +16,14 @@ import { KeyService } from "@bitwarden/key-management"; @Directive() export class FolderAddEditComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() folderId: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onSavedFolder = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onDeletedFolder = new EventEmitter(); editMode = false; diff --git a/libs/angular/src/vault/components/icon.component.ts b/libs/angular/src/vault/components/icon.component.ts index ee2b535d716..851cec5656b 100644 --- a/libs/angular/src/vault/components/icon.component.ts +++ b/libs/angular/src/vault/components/icon.component.ts @@ -25,14 +25,14 @@ export class IconComponent { /** * The cipher to display the icon for. */ - cipher = input.required(); + readonly cipher = input.required(); /** * coloredIcon will adjust the size of favicons and the colors of the text icon when user is in the item details view. */ - coloredIcon = input(false); + readonly coloredIcon = input(false); - imageLoaded = signal(false); + readonly imageLoaded = signal(false); protected data$: Observable; diff --git a/libs/angular/src/vault/components/spotlight/spotlight.component.ts b/libs/angular/src/vault/components/spotlight/spotlight.component.ts index 3c64318a900..a912e4ce11b 100644 --- a/libs/angular/src/vault/components/spotlight/spotlight.component.ts +++ b/libs/angular/src/vault/components/spotlight/spotlight.component.ts @@ -4,6 +4,8 @@ import { Component, EventEmitter, Input, Output } from "@angular/core"; import { ButtonModule, IconButtonModule, TypographyModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; +// 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-spotlight", templateUrl: "spotlight.component.html", @@ -11,16 +13,30 @@ import { I18nPipe } from "@bitwarden/ui-common"; }) export class SpotlightComponent { // The title of the component + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) title: string | null = null; // The subtitle of the component + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() subtitle?: string | null = null; // The text to display on the button + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() buttonText?: string; // Wheter the component can be dismissed, if true, the component will not show a close button + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() persistent = false; // Optional icon to display on the button + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() buttonIcon: string | null = null; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onDismiss = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onButtonClick = new EventEmitter(); handleButtonClick(event: MouseEvent): void { diff --git a/libs/angular/src/vault/components/vault-items.component.ts b/libs/angular/src/vault/components/vault-items.component.ts index 414ec1509ed..0254ddabf2b 100644 --- a/libs/angular/src/vault/components/vault-items.component.ts +++ b/libs/angular/src/vault/components/vault-items.component.ts @@ -31,10 +31,20 @@ import { @Directive() export class VaultItemsComponent implements OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() activeCipherId: string = null; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onCipherClicked = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onCipherRightClicked = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onAddCipher = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onAddCipherOptions = new EventEmitter(); loaded = false; diff --git a/libs/angular/src/vault/vault-filter/components/collection-filter.component.ts b/libs/angular/src/vault/vault-filter/components/collection-filter.component.ts index e9a6923c2fb..4d4037a3517 100644 --- a/libs/angular/src/vault/vault-filter/components/collection-filter.component.ts +++ b/libs/angular/src/vault/vault-filter/components/collection-filter.component.ts @@ -13,13 +13,25 @@ import { VaultFilter } from "../models/vault-filter.model"; @Directive() export class CollectionFilterComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() hide = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() collapsedFilterNodes: Set; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() collectionNodes: DynamicTreeNode; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() activeFilter: VaultFilter; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onNodeCollapseStateChange: EventEmitter = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onFilterChange: EventEmitter = new EventEmitter(); DefaultCollectionType = CollectionTypes.DefaultUserCollection; diff --git a/libs/angular/src/vault/vault-filter/components/folder-filter.component.ts b/libs/angular/src/vault/vault-filter/components/folder-filter.component.ts index 45605d583aa..8c47a37b31b 100644 --- a/libs/angular/src/vault/vault-filter/components/folder-filter.component.ts +++ b/libs/angular/src/vault/vault-filter/components/folder-filter.component.ts @@ -11,15 +11,31 @@ import { VaultFilter } from "../models/vault-filter.model"; @Directive() export class FolderFilterComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() hide = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() collapsedFilterNodes: Set; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() folderNodes: DynamicTreeNode; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() activeFilter: VaultFilter; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onNodeCollapseStateChange: EventEmitter = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onFilterChange: EventEmitter = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onAddFolder = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onEditFolder = new EventEmitter(); get folders() { diff --git a/libs/angular/src/vault/vault-filter/components/organization-filter.component.ts b/libs/angular/src/vault/vault-filter/components/organization-filter.component.ts index 45198d2bcc5..46be2df3884 100644 --- a/libs/angular/src/vault/vault-filter/components/organization-filter.component.ts +++ b/libs/angular/src/vault/vault-filter/components/organization-filter.component.ts @@ -11,15 +11,31 @@ import { VaultFilter } from "../models/vault-filter.model"; @Directive() export class OrganizationFilterComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() hide = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() collapsedFilterNodes: Set; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() organizations: Organization[]; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() activeFilter: VaultFilter; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() activeOrganizationDataOwnership: boolean; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() activeSingleOrganizationPolicy: boolean; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onNodeCollapseStateChange: EventEmitter = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onFilterChange: EventEmitter = new EventEmitter(); get displayMode(): DisplayMode { diff --git a/libs/angular/src/vault/vault-filter/components/status-filter.component.ts b/libs/angular/src/vault/vault-filter/components/status-filter.component.ts index dc6a90f928d..6862019ab4e 100644 --- a/libs/angular/src/vault/vault-filter/components/status-filter.component.ts +++ b/libs/angular/src/vault/vault-filter/components/status-filter.component.ts @@ -7,10 +7,20 @@ import { VaultFilter } from "../models/vault-filter.model"; @Directive() export class StatusFilterComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() hideFavorites = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() hideTrash = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() hideArchive = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onFilterChange: EventEmitter = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() activeFilter: VaultFilter; get show() { diff --git a/libs/angular/src/vault/vault-filter/components/type-filter.component.ts b/libs/angular/src/vault/vault-filter/components/type-filter.component.ts index 84cdf976309..a06be5e4b08 100644 --- a/libs/angular/src/vault/vault-filter/components/type-filter.component.ts +++ b/libs/angular/src/vault/vault-filter/components/type-filter.component.ts @@ -10,13 +10,25 @@ import { VaultFilter } from "../models/vault-filter.model"; @Directive() export class TypeFilterComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() hide = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() collapsedFilterNodes: Set; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() selectedCipherType: CipherType = null; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() activeFilter: VaultFilter; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onNodeCollapseStateChange: EventEmitter = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onFilterChange: EventEmitter = new EventEmitter(); readonly typesNode: TopLevelTreeNode = { diff --git a/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts b/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts index 9199c53bfcb..9b1d6286a9a 100644 --- a/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts +++ b/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts @@ -22,15 +22,33 @@ import { VaultFilter } from "../models/vault-filter.model"; // and refactor desktop/browser vault filters @Directive() export class VaultFilterComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() activeFilter: VaultFilter = new VaultFilter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() hideFolders = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() hideCollections = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() hideFavorites = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() hideTrash = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() hideOrganizations = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onFilterChange = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onAddFolder = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onEditFolder = new EventEmitter(); private activeUserId: UserId; 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/admin-console/models/domain/organization.spec.ts b/libs/common/src/admin-console/models/domain/organization.spec.ts index ddf1010eea9..cc158c71056 100644 --- a/libs/common/src/admin-console/models/domain/organization.spec.ts +++ b/libs/common/src/admin-console/models/domain/organization.spec.ts @@ -111,6 +111,28 @@ describe("Organization", () => { expect(organization.canManageDeviceApprovals).toBe(false); }); + it("should return false when ssoEnabled is false", () => { + data.type = OrganizationUserType.Admin; + data.useSso = true; + data.ssoEnabled = false; + data.ssoMemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; + + const organization = new Organization(data); + + expect(organization.canManageDeviceApprovals).toBe(false); + }); + + it("should return false when ssoMemberDecryptionType is not TrustedDeviceEncryption", () => { + data.type = OrganizationUserType.Admin; + data.useSso = true; + data.ssoEnabled = true; + data.ssoMemberDecryptionType = MemberDecryptionType.MasterPassword; + + const organization = new Organization(data); + + expect(organization.canManageDeviceApprovals).toBe(false); + }); + it("should return true when admin has all required SSO settings enabled", () => { data.type = OrganizationUserType.Admin; data.useSso = true; diff --git a/libs/common/src/admin-console/models/domain/organization.ts b/libs/common/src/admin-console/models/domain/organization.ts index aea796dfc39..f320a675b62 100644 --- a/libs/common/src/admin-console/models/domain/organization.ts +++ b/libs/common/src/admin-console/models/domain/organization.ts @@ -311,7 +311,12 @@ export class Organization { } get canManageDeviceApprovals() { - return (this.isAdmin || this.permissions.manageResetPassword) && this.useSso; + return ( + (this.isAdmin || this.permissions.manageResetPassword) && + this.useSso && + this.ssoEnabled && + this.ssoMemberDecryptionType === MemberDecryptionType.TrustedDeviceEncryption + ); } get isExemptFromPolicies() { 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/platform/models/domain/domain-base.ts b/libs/common/src/platform/models/domain/domain-base.ts index bab9f0f8ac7..a144353f5bc 100644 --- a/libs/common/src/platform/models/domain/domain-base.ts +++ b/libs/common/src/platform/models/domain/domain-base.ts @@ -14,15 +14,15 @@ export type DecryptedObject< // extracts shared keys from the domain and view types type EncryptableKeys = (keyof D & - ConditionalKeys) & - (keyof V & ConditionalKeys); + ConditionalKeys) & + (keyof V & ConditionalKeys); type DomainEncryptableKeys = { - [key in ConditionalKeys]: EncString | null; + [key in ConditionalKeys]?: EncString | null | undefined; }; type ViewEncryptableKeys = { - [key in ConditionalKeys]: string | null; + [key in ConditionalKeys]?: string | null | undefined; }; // https://contributing.bitwarden.com/architecture/clients/data-model#domain diff --git a/libs/common/src/vault/models/api/cipher-permissions.api.ts b/libs/common/src/vault/models/api/cipher-permissions.api.ts index f9b62c4fc8d..cca5ffce79e 100644 --- a/libs/common/src/vault/models/api/cipher-permissions.api.ts +++ b/libs/common/src/vault/models/api/cipher-permissions.api.ts @@ -24,7 +24,9 @@ export class CipherPermissionsApi extends BaseResponse implements SdkCipherPermi /** * Converts the SDK CipherPermissionsApi to a CipherPermissionsApi. */ - static fromSdkCipherPermissions(obj: SdkCipherPermissions): CipherPermissionsApi | undefined { + static fromSdkCipherPermissions( + obj: SdkCipherPermissions | undefined, + ): CipherPermissionsApi | undefined { if (!obj) { return undefined; } diff --git a/libs/common/src/vault/models/domain/attachment.spec.ts b/libs/common/src/vault/models/domain/attachment.spec.ts index 93f693f14c0..972c77537ff 100644 --- a/libs/common/src/vault/models/domain/attachment.spec.ts +++ b/libs/common/src/vault/models/domain/attachment.spec.ts @@ -32,12 +32,12 @@ describe("Attachment", () => { const attachment = new Attachment(data); expect(attachment).toEqual({ - id: null, - url: null, + id: undefined, + url: undefined, size: undefined, - sizeName: null, - key: null, - fileName: null, + sizeName: undefined, + key: undefined, + fileName: undefined, }); }); @@ -79,6 +79,8 @@ describe("Attachment", () => { attachment.key = mockEnc("key"); attachment.fileName = mockEnc("fileName"); + const userKey = new SymmetricCryptoKey(makeStaticByteArray(64)); + keyService.getUserKey.mockResolvedValue(userKey as UserKey); encryptService.decryptFileData.mockResolvedValue(makeStaticByteArray(32)); encryptService.unwrapSymmetricKey.mockResolvedValue( new SymmetricCryptoKey(makeStaticByteArray(64)), @@ -152,8 +154,8 @@ describe("Attachment", () => { expect(actual).toBeInstanceOf(Attachment); }); - it("returns null if object is null", () => { - expect(Attachment.fromJSON(null)).toBeNull(); + it("returns undefined if object is null", () => { + expect(Attachment.fromJSON(null)).toBeUndefined(); }); }); diff --git a/libs/common/src/vault/models/domain/attachment.ts b/libs/common/src/vault/models/domain/attachment.ts index 4ace8ce0e77..7b43af9be55 100644 --- a/libs/common/src/vault/models/domain/attachment.ts +++ b/libs/common/src/vault/models/domain/attachment.ts @@ -1,23 +1,23 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Jsonify } from "type-fest"; +import { OrgKey, UserKey } from "@bitwarden/common/types/key"; import { Attachment as SdkAttachment } from "@bitwarden/sdk-internal"; import { EncString } from "../../../key-management/crypto/models/enc-string"; import { Utils } from "../../../platform/misc/utils"; import Domain from "../../../platform/models/domain/domain-base"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { conditionalEncString, encStringFrom } from "../../utils/domain-utils"; import { AttachmentData } from "../data/attachment.data"; import { AttachmentView } from "../view/attachment.view"; export class Attachment extends Domain { - id: string; - url: string; - size: string; - sizeName: string; // Readable size, ex: "4.2 KB" or "1.43 GB" - key: EncString; - fileName: EncString; + id?: string; + url?: string; + size?: string; + sizeName?: string; // Readable size, ex: "4.2 KB" or "1.43 GB" + key?: EncString; + fileName?: EncString; constructor(obj?: AttachmentData) { super(); @@ -25,32 +25,24 @@ export class Attachment extends Domain { return; } + this.id = obj.id; + this.url = obj.url; this.size = obj.size; - this.buildDomainModel( - this, - obj, - { - id: null, - url: null, - sizeName: null, - fileName: null, - key: null, - }, - ["id", "url", "sizeName"], - ); + this.sizeName = obj.sizeName; + this.fileName = conditionalEncString(obj.fileName); + this.key = conditionalEncString(obj.key); } async decrypt( - orgId: string, + orgId: string | undefined, context = "No Cipher Context", encKey?: SymmetricCryptoKey, ): Promise { const view = await this.decryptObj( this, - // @ts-expect-error ViewEncryptableKeys type should be fixed to allow for optional values, but is out of scope for now. new AttachmentView(this), ["fileName"], - orgId, + orgId ?? null, encKey, "DomainType: Attachment; " + context, ); @@ -63,30 +55,46 @@ export class Attachment extends Domain { return view; } - private async decryptAttachmentKey(orgId: string, encKey?: SymmetricCryptoKey) { + private async decryptAttachmentKey( + orgId: string | undefined, + encKey?: SymmetricCryptoKey, + ): Promise { try { + if (this.key == null) { + return undefined; + } + if (encKey == null) { - encKey = await this.getKeyForDecryption(orgId); + const key = await this.getKeyForDecryption(orgId); + + // If we don't have a key, we can't decrypt + if (key == null) { + return undefined; + } + + encKey = key; } const encryptService = Utils.getContainerService().getEncryptService(); const decValue = await encryptService.unwrapSymmetricKey(this.key, encKey); return decValue; - // FIXME: Remove when updating file. Eslint update - // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { - // TODO: error? + // eslint-disable-next-line no-console + console.error("[Attachment] Error decrypting attachment", e); + return undefined; } } - private async getKeyForDecryption(orgId: string) { + private async getKeyForDecryption(orgId: string | undefined): Promise { const keyService = Utils.getContainerService().getKeyService(); return orgId != null ? await keyService.getOrgKey(orgId) : await keyService.getUserKey(); } toAttachmentData(): AttachmentData { const a = new AttachmentData(); - a.size = this.size; + if (this.size != null) { + a.size = this.size; + } this.buildDataModel( this, a, @@ -102,18 +110,20 @@ export class Attachment extends Domain { return a; } - static fromJSON(obj: Partial>): Attachment { + static fromJSON(obj: Partial> | undefined): Attachment | undefined { if (obj == null) { - return null; + return undefined; } - const key = EncString.fromJSON(obj.key); - const fileName = EncString.fromJSON(obj.fileName); + const attachment = new Attachment(); + attachment.id = obj.id; + attachment.url = obj.url; + attachment.size = obj.size; + attachment.sizeName = obj.sizeName; + attachment.key = encStringFrom(obj.key); + attachment.fileName = encStringFrom(obj.fileName); - return Object.assign(new Attachment(), obj, { - key, - fileName, - }); + return attachment; } /** @@ -136,7 +146,7 @@ export class Attachment extends Domain { * Maps an SDK Attachment object to an Attachment * @param obj - The SDK attachment object */ - static fromSdkAttachment(obj: SdkAttachment): Attachment | undefined { + static fromSdkAttachment(obj?: SdkAttachment): Attachment | undefined { if (!obj) { return undefined; } @@ -146,8 +156,8 @@ export class Attachment extends Domain { attachment.url = obj.url; attachment.size = obj.size; attachment.sizeName = obj.sizeName; - attachment.fileName = EncString.fromJSON(obj.fileName); - attachment.key = EncString.fromJSON(obj.key); + attachment.fileName = encStringFrom(obj.fileName); + attachment.key = encStringFrom(obj.key); return attachment; } diff --git a/libs/common/src/vault/models/domain/card.spec.ts b/libs/common/src/vault/models/domain/card.spec.ts index 4da62c631d6..a4d242329a4 100644 --- a/libs/common/src/vault/models/domain/card.spec.ts +++ b/libs/common/src/vault/models/domain/card.spec.ts @@ -22,12 +22,12 @@ describe("Card", () => { const card = new Card(data); expect(card).toEqual({ - cardholderName: null, - brand: null, - number: null, - expMonth: null, - expYear: null, - code: null, + cardholderName: undefined, + brand: undefined, + number: undefined, + expMonth: undefined, + expYear: undefined, + code: undefined, }); }); @@ -94,8 +94,8 @@ describe("Card", () => { expect(actual).toBeInstanceOf(Card); }); - it("returns null if object is null", () => { - expect(Card.fromJSON(null)).toBeNull(); + it("returns undefined if object is null", () => { + expect(Card.fromJSON(null)).toBeUndefined(); }); }); diff --git a/libs/common/src/vault/models/domain/card.ts b/libs/common/src/vault/models/domain/card.ts index 89cc361b454..b3a087d44fb 100644 --- a/libs/common/src/vault/models/domain/card.ts +++ b/libs/common/src/vault/models/domain/card.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Jsonify } from "type-fest"; import { Card as SdkCard } from "@bitwarden/sdk-internal"; @@ -7,16 +5,17 @@ import { Card as SdkCard } from "@bitwarden/sdk-internal"; import { EncString } from "../../../key-management/crypto/models/enc-string"; import Domain from "../../../platform/models/domain/domain-base"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { conditionalEncString, encStringFrom } from "../../utils/domain-utils"; import { CardData } from "../data/card.data"; import { CardView } from "../view/card.view"; export class Card extends Domain { - cardholderName: EncString; - brand: EncString; - number: EncString; - expMonth: EncString; - expYear: EncString; - code: EncString; + cardholderName?: EncString; + brand?: EncString; + number?: EncString; + expMonth?: EncString; + expYear?: EncString; + code?: EncString; constructor(obj?: CardData) { super(); @@ -24,23 +23,16 @@ export class Card extends Domain { return; } - this.buildDomainModel( - this, - obj, - { - cardholderName: null, - brand: null, - number: null, - expMonth: null, - expYear: null, - code: null, - }, - [], - ); + this.cardholderName = conditionalEncString(obj.cardholderName); + this.brand = conditionalEncString(obj.brand); + this.number = conditionalEncString(obj.number); + this.expMonth = conditionalEncString(obj.expMonth); + this.expYear = conditionalEncString(obj.expYear); + this.code = conditionalEncString(obj.code); } async decrypt( - orgId: string, + orgId: string | undefined, context = "No Cipher Context", encKey?: SymmetricCryptoKey, ): Promise { @@ -48,7 +40,7 @@ export class Card extends Domain { this, new CardView(), ["cardholderName", "brand", "number", "expMonth", "expYear", "code"], - orgId, + orgId ?? null, encKey, "DomainType: Card; " + context, ); @@ -67,25 +59,20 @@ export class Card extends Domain { return c; } - static fromJSON(obj: Partial>): Card { + static fromJSON(obj: Partial> | undefined): Card | undefined { if (obj == null) { - return null; + return undefined; } - const cardholderName = EncString.fromJSON(obj.cardholderName); - const brand = EncString.fromJSON(obj.brand); - const number = EncString.fromJSON(obj.number); - const expMonth = EncString.fromJSON(obj.expMonth); - const expYear = EncString.fromJSON(obj.expYear); - const code = EncString.fromJSON(obj.code); - return Object.assign(new Card(), obj, { - cardholderName, - brand, - number, - expMonth, - expYear, - code, - }); + const card = new Card(); + card.cardholderName = encStringFrom(obj.cardholderName); + card.brand = encStringFrom(obj.brand); + card.number = encStringFrom(obj.number); + card.expMonth = encStringFrom(obj.expMonth); + card.expYear = encStringFrom(obj.expYear); + card.code = encStringFrom(obj.code); + + return card; } /** @@ -108,18 +95,18 @@ export class Card extends Domain { * Maps an SDK Card object to a Card * @param obj - The SDK Card object */ - static fromSdkCard(obj: SdkCard): Card | undefined { - if (obj == null) { + static fromSdkCard(obj?: SdkCard): Card | undefined { + if (!obj) { return undefined; } const card = new Card(); - card.cardholderName = EncString.fromJSON(obj.cardholderName); - card.brand = EncString.fromJSON(obj.brand); - card.number = EncString.fromJSON(obj.number); - card.expMonth = EncString.fromJSON(obj.expMonth); - card.expYear = EncString.fromJSON(obj.expYear); - card.code = EncString.fromJSON(obj.code); + card.cardholderName = encStringFrom(obj.cardholderName); + card.brand = encStringFrom(obj.brand); + card.number = encStringFrom(obj.number); + card.expMonth = encStringFrom(obj.expMonth); + card.expYear = encStringFrom(obj.expYear); + card.code = encStringFrom(obj.code); return card; } diff --git a/libs/common/src/vault/models/domain/cipher.spec.ts b/libs/common/src/vault/models/domain/cipher.spec.ts index c2cb99740db..4052c9e5338 100644 --- a/libs/common/src/vault/models/domain/cipher.spec.ts +++ b/libs/common/src/vault/models/domain/cipher.spec.ts @@ -44,31 +44,28 @@ describe("Cipher DTO", () => { const data = new CipherData(); const cipher = new Cipher(data); - expect(cipher).toEqual({ - initializerKey: InitializerKey.Cipher, - id: null, - organizationId: null, - folderId: null, - name: null, - notes: null, - type: undefined, - favorite: undefined, - organizationUseTotp: undefined, - edit: undefined, - viewPassword: true, - revisionDate: null, - collectionIds: undefined, - localData: null, - creationDate: null, - deletedDate: undefined, - reprompt: undefined, - attachments: null, - fields: null, - passwordHistory: null, - key: null, - permissions: undefined, - archivedDate: undefined, - }); + expect(cipher.id).toBeUndefined(); + expect(cipher.organizationId).toBeUndefined(); + expect(cipher.folderId).toBeUndefined(); + expect(cipher.name).toBeInstanceOf(EncString); + expect(cipher.notes).toBeUndefined(); + expect(cipher.type).toBeUndefined(); + expect(cipher.favorite).toBeUndefined(); + expect(cipher.organizationUseTotp).toBeUndefined(); + expect(cipher.edit).toBeUndefined(); + expect(cipher.viewPassword).toBeUndefined(); + expect(cipher.revisionDate).toBeInstanceOf(Date); + expect(cipher.collectionIds).toEqual([]); + expect(cipher.localData).toBeUndefined(); + expect(cipher.creationDate).toBeInstanceOf(Date); + expect(cipher.deletedDate).toBeUndefined(); + expect(cipher.reprompt).toBeUndefined(); + expect(cipher.attachments).toBeUndefined(); + expect(cipher.fields).toBeUndefined(); + expect(cipher.passwordHistory).toBeUndefined(); + expect(cipher.key).toBeUndefined(); + expect(cipher.permissions).toBeUndefined(); + expect(cipher.archivedDate).toBeUndefined(); }); it("Decrypt should handle cipher key error", async () => { @@ -121,7 +118,7 @@ describe("Cipher DTO", () => { edit: true, viewPassword: true, decryptionFailure: true, - collectionIds: undefined, + collectionIds: [], revisionDate: new Date("2022-01-31T12:00:00.000Z"), creationDate: new Date("2022-01-01T12:00:00.000Z"), deletedDate: undefined, @@ -155,6 +152,7 @@ describe("Cipher DTO", () => { reprompt: CipherRepromptType.None, key: "EncryptedString", archivedDate: undefined, + collectionIds: [], login: { uris: [ { @@ -223,8 +221,8 @@ describe("Cipher DTO", () => { edit: true, viewPassword: true, revisionDate: new Date("2022-01-31T12:00:00.000Z"), - collectionIds: undefined, - localData: null, + collectionIds: [], + localData: undefined, creationDate: new Date("2022-01-01T12:00:00.000Z"), deletedDate: undefined, permissions: new CipherPermissionsApi(), @@ -265,13 +263,13 @@ describe("Cipher DTO", () => { ], fields: [ { - linkedId: null, + linkedId: undefined, name: { encryptedString: "EncryptedString", encryptionType: 0 }, type: 0, value: { encryptedString: "EncryptedString", encryptionType: 0 }, }, { - linkedId: null, + linkedId: undefined, name: { encryptedString: "EncryptedString", encryptionType: 0 }, type: 1, value: { encryptedString: "EncryptedString", encryptionType: 0 }, @@ -348,7 +346,7 @@ describe("Cipher DTO", () => { attachments: [], fields: [], passwordHistory: [], - collectionIds: undefined, + collectionIds: [], revisionDate: new Date("2022-01-31T12:00:00.000Z"), creationDate: new Date("2022-01-01T12:00:00.000Z"), deletedDate: undefined, @@ -380,6 +378,7 @@ describe("Cipher DTO", () => { deletedDate: undefined, reprompt: CipherRepromptType.None, key: "EncKey", + collectionIds: [], secureNote: { type: SecureNoteType.Generic, }, @@ -404,15 +403,15 @@ describe("Cipher DTO", () => { edit: true, viewPassword: true, revisionDate: new Date("2022-01-31T12:00:00.000Z"), - collectionIds: undefined, - localData: null, + collectionIds: [], + localData: undefined, creationDate: new Date("2022-01-01T12:00:00.000Z"), deletedDate: undefined, reprompt: 0, secureNote: { type: SecureNoteType.Generic }, - attachments: null, - fields: null, - passwordHistory: null, + attachments: undefined, + fields: undefined, + passwordHistory: undefined, key: { encryptedString: "EncKey", encryptionType: 0 }, permissions: new CipherPermissionsApi(), archivedDate: undefined, @@ -475,7 +474,7 @@ describe("Cipher DTO", () => { attachments: [], fields: [], passwordHistory: [], - collectionIds: undefined, + collectionIds: [], revisionDate: new Date("2022-01-31T12:00:00.000Z"), creationDate: new Date("2022-01-01T12:00:00.000Z"), deletedDate: undefined, @@ -507,6 +506,7 @@ describe("Cipher DTO", () => { deletedDate: undefined, permissions: new CipherPermissionsApi(), reprompt: CipherRepromptType.None, + collectionIds: [], card: { cardholderName: "EncryptedString", brand: "EncryptedString", @@ -536,8 +536,8 @@ describe("Cipher DTO", () => { edit: true, viewPassword: true, revisionDate: new Date("2022-01-31T12:00:00.000Z"), - collectionIds: undefined, - localData: null, + collectionIds: [], + localData: undefined, creationDate: new Date("2022-01-01T12:00:00.000Z"), deletedDate: undefined, reprompt: 0, @@ -549,9 +549,9 @@ describe("Cipher DTO", () => { expYear: { encryptedString: "EncryptedString", encryptionType: 0 }, code: { encryptedString: "EncryptedString", encryptionType: 0 }, }, - attachments: null, - fields: null, - passwordHistory: null, + attachments: undefined, + fields: undefined, + passwordHistory: undefined, key: { encryptedString: "EncKey", encryptionType: 0 }, permissions: new CipherPermissionsApi(), archivedDate: undefined, @@ -620,7 +620,7 @@ describe("Cipher DTO", () => { attachments: [], fields: [], passwordHistory: [], - collectionIds: undefined, + collectionIds: [], revisionDate: new Date("2022-01-31T12:00:00.000Z"), creationDate: new Date("2022-01-01T12:00:00.000Z"), deletedDate: undefined, @@ -654,6 +654,7 @@ describe("Cipher DTO", () => { reprompt: CipherRepromptType.None, key: "EncKey", archivedDate: undefined, + collectionIds: [], identity: { title: "EncryptedString", firstName: "EncryptedString", @@ -693,8 +694,8 @@ describe("Cipher DTO", () => { edit: true, viewPassword: true, revisionDate: new Date("2022-01-31T12:00:00.000Z"), - collectionIds: undefined, - localData: null, + collectionIds: [], + localData: undefined, creationDate: new Date("2022-01-01T12:00:00.000Z"), deletedDate: undefined, reprompt: 0, @@ -719,9 +720,9 @@ describe("Cipher DTO", () => { passportNumber: { encryptedString: "EncryptedString", encryptionType: 0 }, licenseNumber: { encryptedString: "EncryptedString", encryptionType: 0 }, }, - attachments: null, - fields: null, - passwordHistory: null, + attachments: undefined, + fields: undefined, + passwordHistory: undefined, key: { encryptedString: "EncKey", encryptionType: 0 }, permissions: new CipherPermissionsApi(), }); @@ -789,7 +790,7 @@ describe("Cipher DTO", () => { attachments: [], fields: [], passwordHistory: [], - collectionIds: undefined, + collectionIds: [], revisionDate: new Date("2022-01-31T12:00:00.000Z"), creationDate: new Date("2022-01-01T12:00:00.000Z"), deletedDate: undefined, @@ -858,8 +859,8 @@ describe("Cipher DTO", () => { expect(actual).toMatchObject(expected); }); - it("returns null if object is null", () => { - expect(Cipher.fromJSON(null)).toBeNull(); + it("returns undefined if object is undefined", () => { + expect(Cipher.fromJSON(undefined)).toBeUndefined(); }); }); diff --git a/libs/common/src/vault/models/domain/cipher.ts b/libs/common/src/vault/models/domain/cipher.ts index 8ba81c7bbd3..5e284232936 100644 --- a/libs/common/src/vault/models/domain/cipher.ts +++ b/libs/common/src/vault/models/domain/cipher.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Jsonify } from "type-fest"; import { Cipher as SdkCipher } from "@bitwarden/sdk-internal"; @@ -13,6 +11,7 @@ import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-cr import { InitializerKey } from "../../../platform/services/cryptography/initializer-key"; import { CipherRepromptType } from "../../enums/cipher-reprompt-type"; import { CipherType } from "../../enums/cipher-type"; +import { conditionalEncString, encStringFrom } from "../../utils/domain-utils"; import { CipherPermissionsApi } from "../api/cipher-permissions.api"; import { CipherData } from "../data/cipher.data"; import { LocalData, fromSdkLocalData, toSdkLocalData } from "../data/local.data"; @@ -33,71 +32,60 @@ import { SshKey } from "./ssh-key"; export class Cipher extends Domain implements Decryptable { readonly initializerKey = InitializerKey.Cipher; - id: string; - organizationId: string; - folderId: string; - name: EncString; - notes: EncString; - type: CipherType; - favorite: boolean; - organizationUseTotp: boolean; - edit: boolean; - viewPassword: boolean; - permissions: CipherPermissionsApi; + id: string = ""; + organizationId?: string; + folderId?: string; + name: EncString = new EncString(""); + notes?: EncString; + type: CipherType = CipherType.Login; + favorite: boolean = false; + organizationUseTotp: boolean = false; + edit: boolean = false; + viewPassword: boolean = true; + permissions?: CipherPermissionsApi; revisionDate: Date; - localData: LocalData; - login: Login; - identity: Identity; - card: Card; - secureNote: SecureNote; - sshKey: SshKey; - attachments: Attachment[]; - fields: Field[]; - passwordHistory: Password[]; - collectionIds: string[]; + localData?: LocalData; + login?: Login; + identity?: Identity; + card?: Card; + secureNote?: SecureNote; + sshKey?: SshKey; + attachments?: Attachment[]; + fields?: Field[]; + passwordHistory?: Password[]; + collectionIds: string[] = []; creationDate: Date; - deletedDate: Date | undefined; - archivedDate: Date | undefined; - reprompt: CipherRepromptType; - key: EncString; + deletedDate?: Date; + archivedDate?: Date; + reprompt: CipherRepromptType = CipherRepromptType.None; + key?: EncString; - constructor(obj?: CipherData, localData: LocalData = null) { + constructor(obj?: CipherData, localData?: LocalData) { super(); if (obj == null) { + this.creationDate = this.revisionDate = new Date(); return; } - this.buildDomainModel( - this, - obj, - { - id: null, - organizationId: null, - folderId: null, - name: null, - notes: null, - key: null, - }, - ["id", "organizationId", "folderId"], - ); - + this.id = obj.id; + this.organizationId = obj.organizationId; + this.folderId = obj.folderId; + this.name = new EncString(obj.name); + this.notes = conditionalEncString(obj.notes); this.type = obj.type; this.favorite = obj.favorite; this.organizationUseTotp = obj.organizationUseTotp; this.edit = obj.edit; - if (obj.viewPassword != null) { - this.viewPassword = obj.viewPassword; - } else { - this.viewPassword = true; // Default for already synced Ciphers without viewPassword - } + this.viewPassword = obj.viewPassword; this.permissions = obj.permissions; - this.revisionDate = obj.revisionDate != null ? new Date(obj.revisionDate) : null; - this.collectionIds = obj.collectionIds; + this.revisionDate = new Date(obj.revisionDate); this.localData = localData; - this.creationDate = obj.creationDate != null ? new Date(obj.creationDate) : null; + this.collectionIds = obj.collectionIds ?? []; + this.creationDate = new Date(obj.creationDate); this.deletedDate = obj.deletedDate != null ? new Date(obj.deletedDate) : undefined; this.archivedDate = obj.archivedDate != null ? new Date(obj.archivedDate) : undefined; this.reprompt = obj.reprompt; + this.key = conditionalEncString(obj.key); switch (this.type) { case CipherType.Login: @@ -121,20 +109,14 @@ export class Cipher extends Domain implements Decryptable { if (obj.attachments != null) { this.attachments = obj.attachments.map((a) => new Attachment(a)); - } else { - this.attachments = null; } if (obj.fields != null) { this.fields = obj.fields.map((f) => new Field(f)); - } else { - this.fields = null; } if (obj.passwordHistory != null) { this.passwordHistory = obj.passwordHistory.map((ph) => new Password(ph)); - } else { - this.passwordHistory = null; } } @@ -161,46 +143,54 @@ export class Cipher extends Domain implements Decryptable { await this.decryptObj( this, - // @ts-expect-error Ciphers have optional Ids which are getting swallowed by the ViewEncryptableKeys type - // The ViewEncryptableKeys type should be fixed to allow for optional Ids, but is out of scope for now. model, ["name", "notes"], - this.organizationId, + this.organizationId ?? null, encKey, ); switch (this.type) { case CipherType.Login: - model.login = await this.login.decrypt( - this.organizationId, - bypassValidation, - `Cipher Id: ${this.id}`, - encKey, - ); + if (this.login != null) { + model.login = await this.login.decrypt( + this.organizationId, + bypassValidation, + `Cipher Id: ${this.id}`, + encKey, + ); + } break; case CipherType.SecureNote: - model.secureNote = await this.secureNote.decrypt( - this.organizationId, - `Cipher Id: ${this.id}`, - encKey, - ); + if (this.secureNote != null) { + model.secureNote = await this.secureNote.decrypt(); + } break; case CipherType.Card: - model.card = await this.card.decrypt(this.organizationId, `Cipher Id: ${this.id}`, encKey); + if (this.card != null) { + model.card = await this.card.decrypt( + this.organizationId, + `Cipher Id: ${this.id}`, + encKey, + ); + } break; case CipherType.Identity: - model.identity = await this.identity.decrypt( - this.organizationId, - `Cipher Id: ${this.id}`, - encKey, - ); + if (this.identity != null) { + model.identity = await this.identity.decrypt( + this.organizationId, + `Cipher Id: ${this.id}`, + encKey, + ); + } break; case CipherType.SshKey: - model.sshKey = await this.sshKey.decrypt( - this.organizationId, - `Cipher Id: ${this.id}`, - encKey, - ); + if (this.sshKey != null) { + model.sshKey = await this.sshKey.decrypt( + this.organizationId, + `Cipher Id: ${this.id}`, + encKey, + ); + } break; default: break; @@ -209,9 +199,12 @@ export class Cipher extends Domain implements Decryptable { if (this.attachments != null && this.attachments.length > 0) { const attachments: AttachmentView[] = []; for (const attachment of this.attachments) { - attachments.push( - await attachment.decrypt(this.organizationId, `Cipher Id: ${this.id}`, encKey), + const decryptedAttachment = await attachment.decrypt( + this.organizationId, + `Cipher Id: ${this.id}`, + encKey, ); + attachments.push(decryptedAttachment); } model.attachments = attachments; } @@ -219,7 +212,8 @@ export class Cipher extends Domain implements Decryptable { if (this.fields != null && this.fields.length > 0) { const fields: FieldView[] = []; for (const field of this.fields) { - fields.push(await field.decrypt(this.organizationId, encKey)); + const decryptedField = await field.decrypt(this.organizationId, encKey); + fields.push(decryptedField); } model.fields = fields; } @@ -227,7 +221,8 @@ export class Cipher extends Domain implements Decryptable { if (this.passwordHistory != null && this.passwordHistory.length > 0) { const passwordHistory: PasswordHistoryView[] = []; for (const ph of this.passwordHistory) { - passwordHistory.push(await ph.decrypt(this.organizationId, encKey)); + const decryptedPh = await ph.decrypt(this.organizationId, encKey); + passwordHistory.push(decryptedPh); } model.passwordHistory = passwordHistory; } @@ -238,20 +233,32 @@ export class Cipher extends Domain implements Decryptable { toCipherData(): CipherData { const c = new CipherData(); c.id = this.id; - c.organizationId = this.organizationId; - c.folderId = this.folderId; + if (this.organizationId != null) { + c.organizationId = this.organizationId; + } + + if (this.folderId != null) { + c.folderId = this.folderId; + } c.edit = this.edit; c.viewPassword = this.viewPassword; c.organizationUseTotp = this.organizationUseTotp; c.favorite = this.favorite; - c.revisionDate = this.revisionDate != null ? this.revisionDate.toISOString() : null; + c.revisionDate = this.revisionDate.toISOString(); c.type = this.type; c.collectionIds = this.collectionIds; - c.creationDate = this.creationDate != null ? this.creationDate.toISOString() : null; + c.creationDate = this.creationDate.toISOString(); c.deletedDate = this.deletedDate != null ? this.deletedDate.toISOString() : undefined; c.reprompt = this.reprompt; - c.key = this.key?.encryptedString; - c.permissions = this.permissions; + + if (this.key != null && this.key.encryptedString != null) { + c.key = this.key.encryptedString; + } + + if (this.permissions != null) { + c.permissions = this.permissions; + } + c.archivedDate = this.archivedDate != null ? this.archivedDate.toISOString() : undefined; this.buildDataModel(this, c, { @@ -261,19 +268,29 @@ export class Cipher extends Domain implements Decryptable { switch (c.type) { case CipherType.Login: - c.login = this.login.toLoginData(); + if (this.login != null) { + c.login = this.login.toLoginData(); + } break; case CipherType.SecureNote: - c.secureNote = this.secureNote.toSecureNoteData(); + if (this.secureNote != null) { + c.secureNote = this.secureNote.toSecureNoteData(); + } break; case CipherType.Card: - c.card = this.card.toCardData(); + if (this.card != null) { + c.card = this.card.toCardData(); + } break; case CipherType.Identity: - c.identity = this.identity.toIdentityData(); + if (this.identity != null) { + c.identity = this.identity.toIdentityData(); + } break; case CipherType.SshKey: - c.sshKey = this.sshKey.toSshKeyData(); + if (this.sshKey != null) { + c.sshKey = this.sshKey.toSshKeyData(); + } break; default: break; @@ -291,51 +308,71 @@ export class Cipher extends Domain implements Decryptable { return c; } - static fromJSON(obj: Jsonify) { + static fromJSON(obj: Jsonify | undefined): Cipher | undefined { if (obj == null) { - return null; + return undefined; } const domain = new Cipher(); - const name = EncString.fromJSON(obj.name); - const notes = EncString.fromJSON(obj.notes); - const creationDate = obj.creationDate == null ? null : new Date(obj.creationDate); - const revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate); - const deletedDate = obj.deletedDate == null ? undefined : new Date(obj.deletedDate); - const attachments = obj.attachments?.map((a: any) => Attachment.fromJSON(a)); - const fields = obj.fields?.map((f: any) => Field.fromJSON(f)); - const passwordHistory = obj.passwordHistory?.map((ph: any) => Password.fromJSON(ph)); - const key = EncString.fromJSON(obj.key); - const archivedDate = obj.archivedDate == null ? undefined : new Date(obj.archivedDate); - Object.assign(domain, obj, { - name, - notes, - creationDate, - revisionDate, - deletedDate, - attachments, - fields, - passwordHistory, - key, - archivedDate, - }); + domain.id = obj.id; + domain.organizationId = obj.organizationId; + domain.folderId = obj.folderId; + domain.type = obj.type; + domain.favorite = obj.favorite; + domain.organizationUseTotp = obj.organizationUseTotp; + domain.edit = obj.edit; + domain.viewPassword = obj.viewPassword; + + if (obj.permissions != null) { + domain.permissions = new CipherPermissionsApi(obj.permissions); + } + + domain.collectionIds = obj.collectionIds; + domain.localData = obj.localData; + domain.reprompt = obj.reprompt; + domain.creationDate = new Date(obj.creationDate); + domain.revisionDate = new Date(obj.revisionDate); + domain.deletedDate = obj.deletedDate != null ? new Date(obj.deletedDate) : undefined; + domain.archivedDate = obj.archivedDate != null ? new Date(obj.archivedDate) : undefined; + domain.name = EncString.fromJSON(obj.name); + domain.notes = encStringFrom(obj.notes); + domain.key = encStringFrom(obj.key); + domain.attachments = obj.attachments + ?.map((a: any) => Attachment.fromJSON(a)) + .filter((a): a is Attachment => a != null); + domain.fields = obj.fields + ?.map((f: any) => Field.fromJSON(f)) + .filter((f): f is Field => f != null); + domain.passwordHistory = obj.passwordHistory + ?.map((ph: any) => Password.fromJSON(ph)) + .filter((ph): ph is Password => ph != null); switch (obj.type) { case CipherType.Card: - domain.card = Card.fromJSON(obj.card); + if (obj.card != null) { + domain.card = Card.fromJSON(obj.card); + } break; case CipherType.Identity: - domain.identity = Identity.fromJSON(obj.identity); + if (obj.identity != null) { + domain.identity = Identity.fromJSON(obj.identity); + } break; case CipherType.Login: - domain.login = Login.fromJSON(obj.login); + if (obj.login != null) { + domain.login = Login.fromJSON(obj.login); + } break; case CipherType.SecureNote: - domain.secureNote = SecureNote.fromJSON(obj.secureNote); + if (obj.secureNote != null) { + domain.secureNote = SecureNote.fromJSON(obj.secureNote); + } break; case CipherType.SshKey: - domain.sshKey = SshKey.fromJSON(obj.sshKey); + if (obj.sshKey != null) { + domain.sshKey = SshKey.fromJSON(obj.sshKey); + } break; default: break; @@ -359,22 +396,22 @@ export class Cipher extends Domain implements Decryptable { name: this.name.toSdk(), notes: this.notes?.toSdk(), type: this.type, - favorite: this.favorite ?? false, - organizationUseTotp: this.organizationUseTotp ?? false, - edit: this.edit ?? true, + favorite: this.favorite, + organizationUseTotp: this.organizationUseTotp, + edit: this.edit, permissions: this.permissions ? { delete: this.permissions.delete, restore: this.permissions.restore, } : undefined, - viewPassword: this.viewPassword ?? true, + viewPassword: this.viewPassword, localData: toSdkLocalData(this.localData), attachments: this.attachments?.map((a) => a.toSdkAttachment()), fields: this.fields?.map((f) => f.toSdkField()), passwordHistory: this.passwordHistory?.map((ph) => ph.toSdkPasswordHistory()), - revisionDate: this.revisionDate?.toISOString(), - creationDate: this.creationDate?.toISOString(), + revisionDate: this.revisionDate.toISOString(), + creationDate: this.creationDate.toISOString(), deletedDate: this.deletedDate?.toISOString(), archivedDate: this.archivedDate?.toISOString(), reprompt: this.reprompt, @@ -388,19 +425,29 @@ export class Cipher extends Domain implements Decryptable { switch (this.type) { case CipherType.Login: - sdkCipher.login = this.login.toSdkLogin(); + if (this.login != null) { + sdkCipher.login = this.login.toSdkLogin(); + } break; case CipherType.SecureNote: - sdkCipher.secureNote = this.secureNote.toSdkSecureNote(); + if (this.secureNote != null) { + sdkCipher.secureNote = this.secureNote.toSdkSecureNote(); + } break; case CipherType.Card: - sdkCipher.card = this.card.toSdkCard(); + if (this.card != null) { + sdkCipher.card = this.card.toSdkCard(); + } break; case CipherType.Identity: - sdkCipher.identity = this.identity.toSdkIdentity(); + if (this.identity != null) { + sdkCipher.identity = this.identity.toSdkIdentity(); + } break; case CipherType.SshKey: - sdkCipher.sshKey = this.sshKey.toSdkSshKey(); + if (this.sshKey != null) { + sdkCipher.sshKey = this.sshKey.toSdkSshKey(); + } break; default: break; @@ -413,22 +460,22 @@ export class Cipher extends Domain implements Decryptable { * Maps an SDK Cipher object to a Cipher * @param sdkCipher - The SDK Cipher object */ - static fromSdkCipher(sdkCipher: SdkCipher | null): Cipher | undefined { + static fromSdkCipher(sdkCipher?: SdkCipher): Cipher | undefined { if (sdkCipher == null) { return undefined; } const cipher = new Cipher(); - cipher.id = sdkCipher.id ? uuidAsString(sdkCipher.id) : undefined; + cipher.id = sdkCipher.id ? uuidAsString(sdkCipher.id) : ""; cipher.organizationId = sdkCipher.organizationId ? uuidAsString(sdkCipher.organizationId) : undefined; cipher.folderId = sdkCipher.folderId ? uuidAsString(sdkCipher.folderId) : undefined; cipher.collectionIds = sdkCipher.collectionIds ? sdkCipher.collectionIds.map(uuidAsString) : []; - cipher.key = EncString.fromJSON(sdkCipher.key); + cipher.key = encStringFrom(sdkCipher.key); cipher.name = EncString.fromJSON(sdkCipher.name); - cipher.notes = EncString.fromJSON(sdkCipher.notes); + cipher.notes = encStringFrom(sdkCipher.notes); cipher.type = sdkCipher.type; cipher.favorite = sdkCipher.favorite; cipher.organizationUseTotp = sdkCipher.organizationUseTotp; @@ -436,10 +483,15 @@ export class Cipher extends Domain implements Decryptable { cipher.permissions = CipherPermissionsApi.fromSdkCipherPermissions(sdkCipher.permissions); cipher.viewPassword = sdkCipher.viewPassword; cipher.localData = fromSdkLocalData(sdkCipher.localData); - cipher.attachments = sdkCipher.attachments?.map((a) => Attachment.fromSdkAttachment(a)) ?? []; - cipher.fields = sdkCipher.fields?.map((f) => Field.fromSdkField(f)) ?? []; - cipher.passwordHistory = - sdkCipher.passwordHistory?.map((ph) => Password.fromSdkPasswordHistory(ph)) ?? []; + cipher.attachments = sdkCipher.attachments + ?.map((a) => Attachment.fromSdkAttachment(a)) + .filter((a): a is Attachment => a != null); + cipher.fields = sdkCipher.fields + ?.map((f) => Field.fromSdkField(f)) + .filter((f): f is Field => f != null); + cipher.passwordHistory = sdkCipher.passwordHistory + ?.map((ph) => Password.fromSdkPasswordHistory(ph)) + .filter((ph): ph is Password => ph != null); cipher.creationDate = new Date(sdkCipher.creationDate); cipher.revisionDate = new Date(sdkCipher.revisionDate); cipher.deletedDate = sdkCipher.deletedDate ? new Date(sdkCipher.deletedDate) : undefined; diff --git a/libs/common/src/vault/models/domain/fido2-credential.spec.ts b/libs/common/src/vault/models/domain/fido2-credential.spec.ts index e245e54de7c..3f43775433e 100644 --- a/libs/common/src/vault/models/domain/fido2-credential.spec.ts +++ b/libs/common/src/vault/models/domain/fido2-credential.spec.ts @@ -13,25 +13,23 @@ describe("Fido2Credential", () => { }); describe("constructor", () => { - it("returns all fields null when given empty data parameter", () => { + it("returns all fields undefined when given empty data parameter", () => { const data = new Fido2CredentialData(); const credential = new Fido2Credential(data); - expect(credential).toEqual({ - credentialId: null, - keyType: null, - keyAlgorithm: null, - keyCurve: null, - keyValue: null, - rpId: null, - userHandle: null, - userName: null, - rpName: null, - userDisplayName: null, - counter: null, - discoverable: null, - creationDate: null, - }); + expect(credential.credentialId).toBeDefined(); + expect(credential.keyType).toBeDefined(); + expect(credential.keyAlgorithm).toBeDefined(); + expect(credential.keyCurve).toBeDefined(); + expect(credential.keyValue).toBeDefined(); + expect(credential.rpId).toBeDefined(); + expect(credential.counter).toBeDefined(); + expect(credential.discoverable).toBeDefined(); + expect(credential.userHandle).toBeUndefined(); + expect(credential.userName).toBeUndefined(); + expect(credential.rpName).toBeUndefined(); + expect(credential.userDisplayName).toBeUndefined(); + expect(credential.creationDate).toBeInstanceOf(Date); }); it("returns all fields as EncStrings except creationDate when given full Fido2CredentialData", () => { @@ -69,12 +67,22 @@ describe("Fido2Credential", () => { }); }); - it("should not populate fields when data parameter is not given", () => { + it("should not populate fields when data parameter is not given except creationDate", () => { const credential = new Fido2Credential(); - expect(credential).toEqual({ - credentialId: null, - }); + expect(credential.credentialId).toBeUndefined(); + expect(credential.keyType).toBeUndefined(); + expect(credential.keyAlgorithm).toBeUndefined(); + expect(credential.keyCurve).toBeUndefined(); + expect(credential.keyValue).toBeUndefined(); + expect(credential.rpId).toBeUndefined(); + expect(credential.userHandle).toBeUndefined(); + expect(credential.userName).toBeUndefined(); + expect(credential.counter).toBeUndefined(); + expect(credential.rpName).toBeUndefined(); + expect(credential.userDisplayName).toBeUndefined(); + expect(credential.discoverable).toBeUndefined(); + expect(credential.creationDate).toBeInstanceOf(Date); }); }); @@ -163,8 +171,8 @@ describe("Fido2Credential", () => { expect(result).toEqual(credential); }); - it("returns null if input is null", () => { - expect(Fido2Credential.fromJSON(null)).toBeNull(); + it("returns undefined if input is null", () => { + expect(Fido2Credential.fromJSON(null)).toBeUndefined(); }); }); diff --git a/libs/common/src/vault/models/domain/fido2-credential.ts b/libs/common/src/vault/models/domain/fido2-credential.ts index bdfac9a85ad..eff95c4d0bd 100644 --- a/libs/common/src/vault/models/domain/fido2-credential.ts +++ b/libs/common/src/vault/models/domain/fido2-credential.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Jsonify } from "type-fest"; import { Fido2Credential as SdkFido2Credential } from "@bitwarden/sdk-internal"; @@ -7,56 +5,53 @@ import { Fido2Credential as SdkFido2Credential } from "@bitwarden/sdk-internal"; import { EncString } from "../../../key-management/crypto/models/enc-string"; import Domain from "../../../platform/models/domain/domain-base"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { conditionalEncString, encStringFrom } from "../../utils/domain-utils"; import { Fido2CredentialData } from "../data/fido2-credential.data"; import { Fido2CredentialView } from "../view/fido2-credential.view"; export class Fido2Credential extends Domain { - credentialId: EncString | null = null; - keyType: EncString; - keyAlgorithm: EncString; - keyCurve: EncString; - keyValue: EncString; - rpId: EncString; - userHandle: EncString; - userName: EncString; - counter: EncString; - rpName: EncString; - userDisplayName: EncString; - discoverable: EncString; - creationDate: Date; + credentialId!: EncString; + keyType!: EncString; + keyAlgorithm!: EncString; + keyCurve!: EncString; + keyValue!: EncString; + rpId!: EncString; + userHandle?: EncString; + userName?: EncString; + counter!: EncString; + rpName?: EncString; + userDisplayName?: EncString; + discoverable!: EncString; + creationDate!: Date; constructor(obj?: Fido2CredentialData) { super(); if (obj == null) { + this.creationDate = new Date(); return; } - this.buildDomainModel( - this, - obj, - { - credentialId: null, - keyType: null, - keyAlgorithm: null, - keyCurve: null, - keyValue: null, - rpId: null, - userHandle: null, - userName: null, - counter: null, - rpName: null, - userDisplayName: null, - discoverable: null, - }, - [], - ); - this.creationDate = obj.creationDate != null ? new Date(obj.creationDate) : null; + this.credentialId = new EncString(obj.credentialId); + this.keyType = new EncString(obj.keyType); + this.keyAlgorithm = new EncString(obj.keyAlgorithm); + this.keyCurve = new EncString(obj.keyCurve); + this.keyValue = new EncString(obj.keyValue); + this.rpId = new EncString(obj.rpId); + this.counter = new EncString(obj.counter); + this.discoverable = new EncString(obj.discoverable); + this.userHandle = conditionalEncString(obj.userHandle); + this.userName = conditionalEncString(obj.userName); + this.rpName = conditionalEncString(obj.rpName); + this.userDisplayName = conditionalEncString(obj.userDisplayName); + this.creationDate = new Date(obj.creationDate); } - async decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise { + async decrypt( + orgId: string | undefined, + encKey?: SymmetricCryptoKey, + ): Promise { const view = await this.decryptObj( this, - // @ts-expect-error ViewEncryptableKeys type should be fixed to allow for optional values, but is out of scope for now. new Fido2CredentialView(), [ "credentialId", @@ -70,7 +65,7 @@ export class Fido2Credential extends Domain { "rpName", "userDisplayName", ], - orgId, + orgId ?? null, encKey, ); @@ -79,7 +74,7 @@ export class Fido2Credential extends Domain { { counter: string; } - >(this, { counter: "" }, ["counter"], orgId, encKey); + >(this, { counter: "" }, ["counter"], orgId ?? null, encKey); // Counter will end up as NaN if this fails view.counter = parseInt(counter); @@ -87,7 +82,7 @@ export class Fido2Credential extends Domain { this, { discoverable: "" }, ["discoverable"], - orgId, + orgId ?? null, encKey, ); view.discoverable = discoverable === "true"; @@ -116,40 +111,28 @@ export class Fido2Credential extends Domain { return i; } - static fromJSON(obj: Jsonify): Fido2Credential { + static fromJSON(obj: Jsonify | undefined): Fido2Credential | undefined { if (obj == null) { - return null; + return undefined; } - const credentialId = EncString.fromJSON(obj.credentialId); - const keyType = EncString.fromJSON(obj.keyType); - const keyAlgorithm = EncString.fromJSON(obj.keyAlgorithm); - const keyCurve = EncString.fromJSON(obj.keyCurve); - const keyValue = EncString.fromJSON(obj.keyValue); - const rpId = EncString.fromJSON(obj.rpId); - const userHandle = EncString.fromJSON(obj.userHandle); - const userName = EncString.fromJSON(obj.userName); - const counter = EncString.fromJSON(obj.counter); - const rpName = EncString.fromJSON(obj.rpName); - const userDisplayName = EncString.fromJSON(obj.userDisplayName); - const discoverable = EncString.fromJSON(obj.discoverable); - const creationDate = obj.creationDate != null ? new Date(obj.creationDate) : null; + const credential = new Fido2Credential(); - return Object.assign(new Fido2Credential(), obj, { - credentialId, - keyType, - keyAlgorithm, - keyCurve, - keyValue, - rpId, - userHandle, - userName, - counter, - rpName, - userDisplayName, - discoverable, - creationDate, - }); + credential.credentialId = EncString.fromJSON(obj.credentialId); + credential.keyType = EncString.fromJSON(obj.keyType); + credential.keyAlgorithm = EncString.fromJSON(obj.keyAlgorithm); + credential.keyCurve = EncString.fromJSON(obj.keyCurve); + credential.keyValue = EncString.fromJSON(obj.keyValue); + credential.rpId = EncString.fromJSON(obj.rpId); + credential.userHandle = encStringFrom(obj.userHandle); + credential.userName = encStringFrom(obj.userName); + credential.counter = EncString.fromJSON(obj.counter); + credential.rpName = encStringFrom(obj.rpName); + credential.userDisplayName = encStringFrom(obj.userDisplayName); + credential.discoverable = EncString.fromJSON(obj.discoverable); + credential.creationDate = new Date(obj.creationDate); + + return credential; } /** @@ -179,8 +162,8 @@ export class Fido2Credential extends Domain { * Maps an SDK Fido2Credential object to a Fido2Credential * @param obj - The SDK Fido2Credential object */ - static fromSdkFido2Credential(obj: SdkFido2Credential): Fido2Credential | undefined { - if (!obj) { + static fromSdkFido2Credential(obj?: SdkFido2Credential): Fido2Credential | undefined { + if (obj == null) { return undefined; } @@ -192,11 +175,11 @@ export class Fido2Credential extends Domain { credential.keyCurve = EncString.fromJSON(obj.keyCurve); credential.keyValue = EncString.fromJSON(obj.keyValue); credential.rpId = EncString.fromJSON(obj.rpId); - credential.userHandle = EncString.fromJSON(obj.userHandle); - credential.userName = EncString.fromJSON(obj.userName); credential.counter = EncString.fromJSON(obj.counter); - credential.rpName = EncString.fromJSON(obj.rpName); - credential.userDisplayName = EncString.fromJSON(obj.userDisplayName); + credential.userHandle = encStringFrom(obj.userHandle); + credential.userName = encStringFrom(obj.userName); + credential.rpName = encStringFrom(obj.rpName); + credential.userDisplayName = encStringFrom(obj.userDisplayName); credential.discoverable = EncString.fromJSON(obj.discoverable); credential.creationDate = new Date(obj.creationDate); diff --git a/libs/common/src/vault/models/domain/field.spec.ts b/libs/common/src/vault/models/domain/field.spec.ts index b5e26199e7d..d99336adad0 100644 --- a/libs/common/src/vault/models/domain/field.spec.ts +++ b/libs/common/src/vault/models/domain/field.spec.ts @@ -30,8 +30,8 @@ describe("Field", () => { expect(field).toEqual({ type: undefined, - name: null, - value: null, + name: undefined, + value: undefined, linkedId: undefined, }); }); @@ -41,9 +41,9 @@ describe("Field", () => { expect(field).toEqual({ type: FieldType.Text, - name: { encryptedString: "encName", encryptionType: 0 }, - value: { encryptedString: "encValue", encryptionType: 0 }, - linkedId: null, + name: new EncString("encName"), + value: new EncString("encValue"), + linkedId: undefined, }); }); @@ -82,12 +82,14 @@ describe("Field", () => { expect(actual).toEqual({ name: "myName_fromJSON", value: "myValue_fromJSON", + type: FieldType.Text, + linkedId: undefined, }); expect(actual).toBeInstanceOf(Field); }); - it("returns null if object is null", () => { - expect(Field.fromJSON(null)).toBeNull(); + it("returns undefined if object is null", () => { + expect(Field.fromJSON(null)).toBeUndefined(); }); }); diff --git a/libs/common/src/vault/models/domain/field.ts b/libs/common/src/vault/models/domain/field.ts index 130d1cc56d5..2ee3a9af8a5 100644 --- a/libs/common/src/vault/models/domain/field.ts +++ b/libs/common/src/vault/models/domain/field.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Jsonify } from "type-fest"; import { Field as SdkField, LinkedIdType as SdkLinkedIdType } from "@bitwarden/sdk-internal"; @@ -8,14 +6,15 @@ import { EncString } from "../../../key-management/crypto/models/enc-string"; import Domain from "../../../platform/models/domain/domain-base"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { FieldType, LinkedIdType } from "../../enums"; +import { conditionalEncString, encStringFrom } from "../../utils/domain-utils"; import { FieldData } from "../data/field.data"; import { FieldView } from "../view/field.view"; export class Field extends Domain { - name: EncString; - value: EncString; - type: FieldType; - linkedId: LinkedIdType; + name?: EncString; + value?: EncString; + type: FieldType = FieldType.Text; + linkedId?: LinkedIdType; constructor(obj?: FieldData) { super(); @@ -24,25 +23,17 @@ export class Field extends Domain { } this.type = obj.type; - this.linkedId = obj.linkedId; - this.buildDomainModel( - this, - obj, - { - name: null, - value: null, - }, - [], - ); + this.linkedId = obj.linkedId ?? undefined; + this.name = conditionalEncString(obj.name); + this.value = conditionalEncString(obj.value); } - decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise { + decrypt(orgId: string | undefined, encKey?: SymmetricCryptoKey): Promise { return this.decryptObj( this, - // @ts-expect-error ViewEncryptableKeys type should be fixed to allow for optional values, but is out of scope for now. new FieldView(this), ["name", "value"], - orgId, + orgId ?? null, encKey, ); } @@ -63,18 +54,18 @@ export class Field extends Domain { return f; } - static fromJSON(obj: Partial>): Field { + static fromJSON(obj: Partial> | undefined): Field | undefined { if (obj == null) { - return null; + return undefined; } - const name = EncString.fromJSON(obj.name); - const value = EncString.fromJSON(obj.value); + const field = new Field(); + field.type = obj.type ?? FieldType.Text; + field.linkedId = obj.linkedId ?? undefined; + field.name = encStringFrom(obj.name); + field.value = encStringFrom(obj.value); - return Object.assign(new Field(), obj, { - name, - value, - }); + return field; } /** @@ -96,14 +87,14 @@ export class Field extends Domain { * Maps SDK Field to Field * @param obj The SDK Field object to map */ - static fromSdkField(obj: SdkField): Field | undefined { - if (!obj) { + static fromSdkField(obj?: SdkField): Field | undefined { + if (obj == null) { return undefined; } const field = new Field(); - field.name = EncString.fromJSON(obj.name); - field.value = EncString.fromJSON(obj.value); + field.name = encStringFrom(obj.name); + field.value = encStringFrom(obj.value); field.type = obj.type; field.linkedId = obj.linkedId; diff --git a/libs/common/src/vault/models/domain/identity.spec.ts b/libs/common/src/vault/models/domain/identity.spec.ts index 9fbcb92e4ae..c2c2363fa0d 100644 --- a/libs/common/src/vault/models/domain/identity.spec.ts +++ b/libs/common/src/vault/models/domain/identity.spec.ts @@ -34,24 +34,24 @@ describe("Identity", () => { const identity = new Identity(data); expect(identity).toEqual({ - address1: null, - address2: null, - address3: null, - city: null, - company: null, - country: null, - email: null, - firstName: null, - lastName: null, - licenseNumber: null, - middleName: null, - passportNumber: null, - phone: null, - postalCode: null, - ssn: null, - state: null, - title: null, - username: null, + address1: undefined, + address2: undefined, + address3: undefined, + city: undefined, + company: undefined, + country: undefined, + email: undefined, + firstName: undefined, + lastName: undefined, + licenseNumber: undefined, + middleName: undefined, + passportNumber: undefined, + phone: undefined, + postalCode: undefined, + ssn: undefined, + state: undefined, + title: undefined, + username: undefined, }); }); @@ -179,8 +179,8 @@ describe("Identity", () => { expect(actual).toBeInstanceOf(Identity); }); - it("returns null if object is null", () => { - expect(Identity.fromJSON(null)).toBeNull(); + it("returns undefined if object is null", () => { + expect(Identity.fromJSON(null)).toBeUndefined(); }); }); diff --git a/libs/common/src/vault/models/domain/identity.ts b/libs/common/src/vault/models/domain/identity.ts index f0d5b3123ab..e2def3eb386 100644 --- a/libs/common/src/vault/models/domain/identity.ts +++ b/libs/common/src/vault/models/domain/identity.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Jsonify } from "type-fest"; import { Identity as SdkIdentity } from "@bitwarden/sdk-internal"; @@ -7,28 +5,29 @@ import { Identity as SdkIdentity } from "@bitwarden/sdk-internal"; import { EncString } from "../../../key-management/crypto/models/enc-string"; import Domain from "../../../platform/models/domain/domain-base"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { conditionalEncString, encStringFrom } from "../../utils/domain-utils"; import { IdentityData } from "../data/identity.data"; import { IdentityView } from "../view/identity.view"; export class Identity extends Domain { - title: EncString; - firstName: EncString; - middleName: EncString; - lastName: EncString; - address1: EncString; - address2: EncString; - address3: EncString; - city: EncString; - state: EncString; - postalCode: EncString; - country: EncString; - company: EncString; - email: EncString; - phone: EncString; - ssn: EncString; - username: EncString; - passportNumber: EncString; - licenseNumber: EncString; + title?: EncString; + firstName?: EncString; + middleName?: EncString; + lastName?: EncString; + address1?: EncString; + address2?: EncString; + address3?: EncString; + city?: EncString; + state?: EncString; + postalCode?: EncString; + country?: EncString; + company?: EncString; + email?: EncString; + phone?: EncString; + ssn?: EncString; + username?: EncString; + passportNumber?: EncString; + licenseNumber?: EncString; constructor(obj?: IdentityData) { super(); @@ -36,35 +35,28 @@ export class Identity extends Domain { return; } - this.buildDomainModel( - this, - obj, - { - title: null, - firstName: null, - middleName: null, - lastName: null, - address1: null, - address2: null, - address3: null, - city: null, - state: null, - postalCode: null, - country: null, - company: null, - email: null, - phone: null, - ssn: null, - username: null, - passportNumber: null, - licenseNumber: null, - }, - [], - ); + this.title = conditionalEncString(obj.title); + this.firstName = conditionalEncString(obj.firstName); + this.middleName = conditionalEncString(obj.middleName); + this.lastName = conditionalEncString(obj.lastName); + this.address1 = conditionalEncString(obj.address1); + this.address2 = conditionalEncString(obj.address2); + this.address3 = conditionalEncString(obj.address3); + this.city = conditionalEncString(obj.city); + this.state = conditionalEncString(obj.state); + this.postalCode = conditionalEncString(obj.postalCode); + this.country = conditionalEncString(obj.country); + this.company = conditionalEncString(obj.company); + this.email = conditionalEncString(obj.email); + this.phone = conditionalEncString(obj.phone); + this.ssn = conditionalEncString(obj.ssn); + this.username = conditionalEncString(obj.username); + this.passportNumber = conditionalEncString(obj.passportNumber); + this.licenseNumber = conditionalEncString(obj.licenseNumber); } decrypt( - orgId: string, + orgId: string | undefined, context: string = "No Cipher Context", encKey?: SymmetricCryptoKey, ): Promise { @@ -91,7 +83,7 @@ export class Identity extends Domain { "passportNumber", "licenseNumber", ], - orgId, + orgId ?? null, encKey, "DomainType: Identity; " + context, ); @@ -122,50 +114,32 @@ export class Identity extends Domain { return i; } - static fromJSON(obj: Jsonify): Identity { + static fromJSON(obj: Jsonify | undefined): Identity | undefined { if (obj == null) { - return null; + return undefined; } - const title = EncString.fromJSON(obj.title); - const firstName = EncString.fromJSON(obj.firstName); - const middleName = EncString.fromJSON(obj.middleName); - const lastName = EncString.fromJSON(obj.lastName); - const address1 = EncString.fromJSON(obj.address1); - const address2 = EncString.fromJSON(obj.address2); - const address3 = EncString.fromJSON(obj.address3); - const city = EncString.fromJSON(obj.city); - const state = EncString.fromJSON(obj.state); - const postalCode = EncString.fromJSON(obj.postalCode); - const country = EncString.fromJSON(obj.country); - const company = EncString.fromJSON(obj.company); - const email = EncString.fromJSON(obj.email); - const phone = EncString.fromJSON(obj.phone); - const ssn = EncString.fromJSON(obj.ssn); - const username = EncString.fromJSON(obj.username); - const passportNumber = EncString.fromJSON(obj.passportNumber); - const licenseNumber = EncString.fromJSON(obj.licenseNumber); + const identity = new Identity(); + identity.title = encStringFrom(obj.title); + identity.firstName = encStringFrom(obj.firstName); + identity.middleName = encStringFrom(obj.middleName); + identity.lastName = encStringFrom(obj.lastName); + identity.address1 = encStringFrom(obj.address1); + identity.address2 = encStringFrom(obj.address2); + identity.address3 = encStringFrom(obj.address3); + identity.city = encStringFrom(obj.city); + identity.state = encStringFrom(obj.state); + identity.postalCode = encStringFrom(obj.postalCode); + identity.country = encStringFrom(obj.country); + identity.company = encStringFrom(obj.company); + identity.email = encStringFrom(obj.email); + identity.phone = encStringFrom(obj.phone); + identity.ssn = encStringFrom(obj.ssn); + identity.username = encStringFrom(obj.username); + identity.passportNumber = encStringFrom(obj.passportNumber); + identity.licenseNumber = encStringFrom(obj.licenseNumber); - return Object.assign(new Identity(), obj, { - title, - firstName, - middleName, - lastName, - address1, - address2, - address3, - city, - state, - postalCode, - country, - company, - email, - phone, - ssn, - username, - passportNumber, - licenseNumber, - }); + return identity; } /** @@ -200,30 +174,30 @@ export class Identity extends Domain { * Maps an SDK Identity object to an Identity * @param obj - The SDK Identity object */ - static fromSdkIdentity(obj: SdkIdentity): Identity | undefined { + static fromSdkIdentity(obj?: SdkIdentity): Identity | undefined { if (obj == null) { return undefined; } const identity = new Identity(); - identity.title = EncString.fromJSON(obj.title); - identity.firstName = EncString.fromJSON(obj.firstName); - identity.middleName = EncString.fromJSON(obj.middleName); - identity.lastName = EncString.fromJSON(obj.lastName); - identity.address1 = EncString.fromJSON(obj.address1); - identity.address2 = EncString.fromJSON(obj.address2); - identity.address3 = EncString.fromJSON(obj.address3); - identity.city = EncString.fromJSON(obj.city); - identity.state = EncString.fromJSON(obj.state); - identity.postalCode = EncString.fromJSON(obj.postalCode); - identity.country = EncString.fromJSON(obj.country); - identity.company = EncString.fromJSON(obj.company); - identity.email = EncString.fromJSON(obj.email); - identity.phone = EncString.fromJSON(obj.phone); - identity.ssn = EncString.fromJSON(obj.ssn); - identity.username = EncString.fromJSON(obj.username); - identity.passportNumber = EncString.fromJSON(obj.passportNumber); - identity.licenseNumber = EncString.fromJSON(obj.licenseNumber); + identity.title = encStringFrom(obj.title); + identity.firstName = encStringFrom(obj.firstName); + identity.middleName = encStringFrom(obj.middleName); + identity.lastName = encStringFrom(obj.lastName); + identity.address1 = encStringFrom(obj.address1); + identity.address2 = encStringFrom(obj.address2); + identity.address3 = encStringFrom(obj.address3); + identity.city = encStringFrom(obj.city); + identity.state = encStringFrom(obj.state); + identity.postalCode = encStringFrom(obj.postalCode); + identity.country = encStringFrom(obj.country); + identity.company = encStringFrom(obj.company); + identity.email = encStringFrom(obj.email); + identity.phone = encStringFrom(obj.phone); + identity.ssn = encStringFrom(obj.ssn); + identity.username = encStringFrom(obj.username); + identity.passportNumber = encStringFrom(obj.passportNumber); + identity.licenseNumber = encStringFrom(obj.licenseNumber); return identity; } diff --git a/libs/common/src/vault/models/domain/login-uri.spec.ts b/libs/common/src/vault/models/domain/login-uri.spec.ts index e67ba771412..982b435384b 100644 --- a/libs/common/src/vault/models/domain/login-uri.spec.ts +++ b/libs/common/src/vault/models/domain/login-uri.spec.ts @@ -27,9 +27,9 @@ describe("LoginUri", () => { const loginUri = new LoginUri(data); expect(loginUri).toEqual({ - match: null, - uri: null, - uriChecksum: null, + match: undefined, + uri: undefined, + uriChecksum: undefined, }); }); @@ -77,7 +77,7 @@ describe("LoginUri", () => { loginUri.uriChecksum = mockEnc("checksum"); encryptService.hash.mockResolvedValue("checksum"); - const actual = await loginUri.validateChecksum("uri", null, null); + const actual = await loginUri.validateChecksum("uri", undefined, undefined); expect(actual).toBe(true); expect(encryptService.hash).toHaveBeenCalledWith("uri", "sha256"); @@ -88,7 +88,7 @@ describe("LoginUri", () => { loginUri.uriChecksum = mockEnc("checksum"); encryptService.hash.mockResolvedValue("incorrect checksum"); - const actual = await loginUri.validateChecksum("uri", null, null); + const actual = await loginUri.validateChecksum("uri", undefined, undefined); expect(actual).toBe(false); }); @@ -112,8 +112,8 @@ describe("LoginUri", () => { expect(actual).toBeInstanceOf(LoginUri); }); - it("returns null if object is null", () => { - expect(LoginUri.fromJSON(null)).toBeNull(); + it("returns undefined if object is null", () => { + expect(LoginUri.fromJSON(null)).toBeUndefined(); }); }); diff --git a/libs/common/src/vault/models/domain/login-uri.ts b/libs/common/src/vault/models/domain/login-uri.ts index 973e25c8ff1..cac487747f8 100644 --- a/libs/common/src/vault/models/domain/login-uri.ts +++ b/libs/common/src/vault/models/domain/login-uri.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Jsonify } from "type-fest"; import { LoginUri as SdkLoginUri } from "@bitwarden/sdk-internal"; @@ -9,13 +7,14 @@ import { UriMatchStrategySetting } from "../../../models/domain/domain-service"; import { Utils } from "../../../platform/misc/utils"; import Domain from "../../../platform/models/domain/domain-base"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { conditionalEncString, encStringFrom } from "../../utils/domain-utils"; import { LoginUriData } from "../data/login-uri.data"; import { LoginUriView } from "../view/login-uri.view"; export class LoginUri extends Domain { - uri: EncString; - uriChecksum: EncString | undefined; - match: UriMatchStrategySetting; + uri?: EncString; + uriChecksum?: EncString; + match?: UriMatchStrategySetting; constructor(obj?: LoginUriData) { super(); @@ -23,20 +22,13 @@ export class LoginUri extends Domain { return; } - this.match = obj.match; - this.buildDomainModel( - this, - obj, - { - uri: null, - uriChecksum: null, - }, - [], - ); + this.uri = conditionalEncString(obj.uri); + this.uriChecksum = conditionalEncString(obj.uriChecksum); + this.match = obj.match ?? undefined; } decrypt( - orgId: string, + orgId: string | undefined, context: string = "No Cipher Context", encKey?: SymmetricCryptoKey, ): Promise { @@ -44,13 +36,13 @@ export class LoginUri extends Domain { this, new LoginUriView(this), ["uri"], - orgId, + orgId ?? null, encKey, context, ); } - async validateChecksum(clearTextUri: string, orgId: string, encKey: SymmetricCryptoKey) { + async validateChecksum(clearTextUri: string, orgId?: string, encKey?: SymmetricCryptoKey) { if (this.uriChecksum == null) { return false; } @@ -58,7 +50,7 @@ export class LoginUri extends Domain { const keyService = Utils.getContainerService().getEncryptService(); const localChecksum = await keyService.hash(clearTextUri, "sha256"); - const remoteChecksum = await this.uriChecksum.decrypt(orgId, encKey); + const remoteChecksum = await this.uriChecksum.decrypt(orgId ?? null, encKey); return remoteChecksum === localChecksum; } @@ -77,17 +69,17 @@ export class LoginUri extends Domain { return u; } - static fromJSON(obj: Jsonify): LoginUri { + static fromJSON(obj: Jsonify | undefined): LoginUri | undefined { if (obj == null) { - return null; + return undefined; } - const uri = EncString.fromJSON(obj.uri); - const uriChecksum = EncString.fromJSON(obj.uriChecksum); - return Object.assign(new LoginUri(), obj, { - uri, - uriChecksum, - }); + const loginUri = new LoginUri(); + loginUri.uri = encStringFrom(obj.uri); + loginUri.match = obj.match ?? undefined; + loginUri.uriChecksum = encStringFrom(obj.uriChecksum); + + return loginUri; } /** @@ -103,16 +95,16 @@ export class LoginUri extends Domain { }; } - static fromSdkLoginUri(obj: SdkLoginUri): LoginUri | undefined { + static fromSdkLoginUri(obj?: SdkLoginUri): LoginUri | undefined { if (obj == null) { return undefined; } - const view = new LoginUri(); - view.uri = EncString.fromJSON(obj.uri); - view.uriChecksum = obj.uriChecksum ? EncString.fromJSON(obj.uriChecksum) : undefined; - view.match = obj.match; + const loginUri = new LoginUri(); + loginUri.uri = encStringFrom(obj.uri); + loginUri.uriChecksum = encStringFrom(obj.uriChecksum); + loginUri.match = obj.match; - return view; + return loginUri; } } diff --git a/libs/common/src/vault/models/domain/login.spec.ts b/libs/common/src/vault/models/domain/login.spec.ts index 99ceb2b0a3d..9f03e225b7f 100644 --- a/libs/common/src/vault/models/domain/login.spec.ts +++ b/libs/common/src/vault/models/domain/login.spec.ts @@ -19,11 +19,11 @@ describe("Login DTO", () => { const login = new Login(data); expect(login).toEqual({ - passwordRevisionDate: null, + passwordRevisionDate: undefined, autofillOnPageLoad: undefined, - username: null, - password: null, - totp: null, + username: undefined, + password: undefined, + totp: undefined, }); }); @@ -193,8 +193,8 @@ describe("Login DTO", () => { expect(actual).toBeInstanceOf(Login); }); - it("returns null if object is null", () => { - expect(Login.fromJSON(null)).toBeNull(); + it("returns undefined if object is null", () => { + expect(Login.fromJSON(null)).toBeUndefined(); }); }); diff --git a/libs/common/src/vault/models/domain/login.ts b/libs/common/src/vault/models/domain/login.ts index b34fb011254..13342c69014 100644 --- a/libs/common/src/vault/models/domain/login.ts +++ b/libs/common/src/vault/models/domain/login.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Jsonify } from "type-fest"; import { Login as SdkLogin } from "@bitwarden/sdk-internal"; @@ -7,6 +5,7 @@ import { Login as SdkLogin } from "@bitwarden/sdk-internal"; import { EncString } from "../../../key-management/crypto/models/enc-string"; import Domain from "../../../platform/models/domain/domain-base"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { conditionalEncString, encStringFrom } from "../../utils/domain-utils"; import { LoginData } from "../data/login.data"; import { LoginView } from "../view/login.view"; @@ -14,13 +13,13 @@ import { Fido2Credential } from "./fido2-credential"; import { LoginUri } from "./login-uri"; export class Login extends Domain { - uris: LoginUri[]; - username: EncString; - password: EncString; + uris?: LoginUri[]; + username?: EncString; + password?: EncString; passwordRevisionDate?: Date; - totp: EncString; - autofillOnPageLoad: boolean; - fido2Credentials: Fido2Credential[]; + totp?: EncString; + autofillOnPageLoad?: boolean; + fido2Credentials?: Fido2Credential[]; constructor(obj?: LoginData) { super(); @@ -29,24 +28,14 @@ export class Login extends Domain { } this.passwordRevisionDate = - obj.passwordRevisionDate != null ? new Date(obj.passwordRevisionDate) : null; + obj.passwordRevisionDate != null ? new Date(obj.passwordRevisionDate) : undefined; this.autofillOnPageLoad = obj.autofillOnPageLoad; - this.buildDomainModel( - this, - obj, - { - username: null, - password: null, - totp: null, - }, - [], - ); + this.username = conditionalEncString(obj.username); + this.password = conditionalEncString(obj.password); + this.totp = conditionalEncString(obj.totp); if (obj.uris) { - this.uris = []; - obj.uris.forEach((u) => { - this.uris.push(new LoginUri(u)); - }); + this.uris = obj.uris.map((u) => new LoginUri(u)); } if (obj.fido2Credentials) { @@ -55,7 +44,7 @@ export class Login extends Domain { } async decrypt( - orgId: string, + orgId: string | undefined, bypassValidation: boolean, context: string = "No Cipher Context", encKey?: SymmetricCryptoKey, @@ -64,7 +53,7 @@ export class Login extends Domain { this, new LoginView(this), ["username", "password", "totp"], - orgId, + orgId ?? null, encKey, `DomainType: Login; ${context}`, ); @@ -78,12 +67,21 @@ export class Login extends Domain { } const uri = await this.uris[i].decrypt(orgId, context, encKey); + const uriString = uri.uri; + + if (uriString == null) { + continue; + } + // URIs are shared remotely after decryption // we need to validate that the string hasn't been changed by a compromised server // This validation is tied to the existence of cypher.key for backwards compatibility - // So we bypass the validation if there's no cipher.key or procceed with the validation and + // So we bypass the validation if there's no cipher.key or proceed with the validation and // Skip the value if it's been tampered with. - if (bypassValidation || (await this.uris[i].validateChecksum(uri.uri, orgId, encKey))) { + const isValidUri = + bypassValidation || (await this.uris[i].validateChecksum(uriString, orgId, encKey)); + + if (isValidUri) { view.uris.push(uri); } } @@ -100,9 +98,12 @@ export class Login extends Domain { toLoginData(): LoginData { const l = new LoginData(); - l.passwordRevisionDate = - this.passwordRevisionDate != null ? this.passwordRevisionDate.toISOString() : null; - l.autofillOnPageLoad = this.autofillOnPageLoad; + if (this.passwordRevisionDate != null) { + l.passwordRevisionDate = this.passwordRevisionDate.toISOString(); + } + if (this.autofillOnPageLoad != null) { + l.autofillOnPageLoad = this.autofillOnPageLoad; + } this.buildDataModel(this, l, { username: null, password: null, @@ -123,28 +124,27 @@ export class Login extends Domain { return l; } - static fromJSON(obj: Partial>): Login { + static fromJSON(obj: Partial> | undefined): Login | undefined { if (obj == null) { - return null; + return undefined; } - const username = EncString.fromJSON(obj.username); - const password = EncString.fromJSON(obj.password); - const totp = EncString.fromJSON(obj.totp); - const passwordRevisionDate = - obj.passwordRevisionDate == null ? null : new Date(obj.passwordRevisionDate); - const uris = obj.uris?.map((uri: any) => LoginUri.fromJSON(uri)); - const fido2Credentials = - obj.fido2Credentials?.map((key) => Fido2Credential.fromJSON(key)) ?? []; + const login = new Login(); + login.passwordRevisionDate = + obj.passwordRevisionDate != null ? new Date(obj.passwordRevisionDate) : undefined; + login.autofillOnPageLoad = obj.autofillOnPageLoad; + login.username = encStringFrom(obj.username); + login.password = encStringFrom(obj.password); + login.totp = encStringFrom(obj.totp); + login.uris = obj.uris + ?.map((uri: any) => LoginUri.fromJSON(uri)) + .filter((u): u is LoginUri => u != null); + login.fido2Credentials = + obj.fido2Credentials + ?.map((key) => Fido2Credential.fromJSON(key)) + .filter((c): c is Fido2Credential => c != null) ?? undefined; - return Object.assign(new Login(), obj, { - username, - password, - totp, - passwordRevisionDate, - uris, - fido2Credentials, - }); + return login; } /** @@ -168,25 +168,27 @@ export class Login extends Domain { * Maps an SDK Login object to a Login * @param obj - The SDK Login object */ - static fromSdkLogin(obj: SdkLogin): Login | undefined { + static fromSdkLogin(obj?: SdkLogin): Login | undefined { if (!obj) { return undefined; } const login = new Login(); - - login.uris = - obj.uris?.filter((u) => u.uri != null).map((uri) => LoginUri.fromSdkLoginUri(uri)) ?? []; - login.username = EncString.fromJSON(obj.username); - login.password = EncString.fromJSON(obj.password); - login.passwordRevisionDate = obj.passwordRevisionDate - ? new Date(obj.passwordRevisionDate) - : undefined; - login.totp = EncString.fromJSON(obj.totp); + login.passwordRevisionDate = + obj.passwordRevisionDate != null ? new Date(obj.passwordRevisionDate) : undefined; login.autofillOnPageLoad = obj.autofillOnPageLoad; - login.fido2Credentials = obj.fido2Credentials?.map((f) => - Fido2Credential.fromSdkFido2Credential(f), - ); + login.username = encStringFrom(obj.username); + login.password = encStringFrom(obj.password); + login.totp = encStringFrom(obj.totp); + login.uris = + obj.uris + ?.filter((u) => u.uri != null) + .map((uri) => LoginUri.fromSdkLoginUri(uri)) + .filter((u): u is LoginUri => u != null) ?? undefined; + login.fido2Credentials = + obj.fido2Credentials + ?.map((f) => Fido2Credential.fromSdkFido2Credential(f)) + .filter((c): c is Fido2Credential => c != null) ?? undefined; return login; } diff --git a/libs/common/src/vault/models/domain/password.spec.ts b/libs/common/src/vault/models/domain/password.spec.ts index a75fca048fe..2e37c5e8375 100644 --- a/libs/common/src/vault/models/domain/password.spec.ts +++ b/libs/common/src/vault/models/domain/password.spec.ts @@ -17,9 +17,9 @@ describe("Password", () => { const data = new PasswordHistoryData(); const password = new Password(data); - expect(password).toMatchObject({ - password: null, - }); + expect(password).toBeInstanceOf(Password); + expect(password.password).toBeInstanceOf(EncString); + expect(password.lastUsedDate).toBeInstanceOf(Date); }); it("Convert", () => { @@ -66,8 +66,8 @@ describe("Password", () => { expect(actual).toBeInstanceOf(Password); }); - it("returns null if object is null", () => { - expect(Password.fromJSON(null)).toBeNull(); + it("returns undefined if object is null", () => { + expect(Password.fromJSON(null)).toBeUndefined(); }); }); diff --git a/libs/common/src/vault/models/domain/password.ts b/libs/common/src/vault/models/domain/password.ts index ca594075e0b..84e8919b905 100644 --- a/libs/common/src/vault/models/domain/password.ts +++ b/libs/common/src/vault/models/domain/password.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Jsonify } from "type-fest"; import { PasswordHistory } from "@bitwarden/sdk-internal"; @@ -11,8 +9,8 @@ import { PasswordHistoryData } from "../data/password-history.data"; import { PasswordHistoryView } from "../view/password-history.view"; export class Password extends Domain { - password: EncString; - lastUsedDate: Date; + password!: EncString; + lastUsedDate!: Date; constructor(obj?: PasswordHistoryData) { super(); @@ -20,18 +18,16 @@ export class Password extends Domain { return; } - this.buildDomainModel(this, obj, { - password: null, - }); + this.password = new EncString(obj.password); this.lastUsedDate = new Date(obj.lastUsedDate); } - decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise { + decrypt(orgId: string | undefined, encKey?: SymmetricCryptoKey): Promise { return this.decryptObj( this, new PasswordHistoryView(this), ["password"], - orgId, + orgId ?? null, encKey, "DomainType: PasswordHistory", ); @@ -46,18 +42,16 @@ export class Password extends Domain { return ph; } - static fromJSON(obj: Partial>): Password { + static fromJSON(obj: Jsonify | undefined): Password | undefined { if (obj == null) { - return null; + return undefined; } - const password = EncString.fromJSON(obj.password); - const lastUsedDate = obj.lastUsedDate == null ? null : new Date(obj.lastUsedDate); + const passwordHistory = new Password(); + passwordHistory.password = EncString.fromJSON(obj.password); + passwordHistory.lastUsedDate = new Date(obj.lastUsedDate); - return Object.assign(new Password(), obj, { - password, - lastUsedDate, - }); + return passwordHistory; } /** @@ -76,7 +70,7 @@ export class Password extends Domain { * Maps an SDK PasswordHistory object to a Password * @param obj - The SDK PasswordHistory object */ - static fromSdkPasswordHistory(obj: PasswordHistory): Password | undefined { + static fromSdkPasswordHistory(obj?: PasswordHistory): Password | undefined { if (!obj) { return undefined; } diff --git a/libs/common/src/vault/models/domain/secure-note.spec.ts b/libs/common/src/vault/models/domain/secure-note.spec.ts index ff71e53238d..4c8e8d470ca 100644 --- a/libs/common/src/vault/models/domain/secure-note.spec.ts +++ b/libs/common/src/vault/models/domain/secure-note.spec.ts @@ -38,7 +38,7 @@ describe("SecureNote", () => { const secureNote = new SecureNote(); secureNote.type = SecureNoteType.Generic; - const view = await secureNote.decrypt(null); + const view = await secureNote.decrypt(); expect(view).toEqual({ type: 0, @@ -46,8 +46,8 @@ describe("SecureNote", () => { }); describe("fromJSON", () => { - it("returns null if object is null", () => { - expect(SecureNote.fromJSON(null)).toBeNull(); + it("returns undefined if object is null", () => { + expect(SecureNote.fromJSON(null)).toBeUndefined(); }); }); diff --git a/libs/common/src/vault/models/domain/secure-note.ts b/libs/common/src/vault/models/domain/secure-note.ts index 1426ff85eab..fb568f482b0 100644 --- a/libs/common/src/vault/models/domain/secure-note.ts +++ b/libs/common/src/vault/models/domain/secure-note.ts @@ -1,17 +1,14 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Jsonify } from "type-fest"; import { SecureNote as SdkSecureNote } from "@bitwarden/sdk-internal"; import Domain from "../../../platform/models/domain/domain-base"; -import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { SecureNoteType } from "../../enums"; import { SecureNoteData } from "../data/secure-note.data"; import { SecureNoteView } from "../view/secure-note.view"; export class SecureNote extends Domain { - type: SecureNoteType; + type: SecureNoteType = SecureNoteType.Generic; constructor(obj?: SecureNoteData) { super(); @@ -22,11 +19,7 @@ export class SecureNote extends Domain { this.type = obj.type; } - async decrypt( - orgId: string, - context = "No Cipher Context", - encKey?: SymmetricCryptoKey, - ): Promise { + async decrypt(): Promise { return new SecureNoteView(this); } @@ -36,12 +29,14 @@ export class SecureNote extends Domain { return n; } - static fromJSON(obj: Jsonify): SecureNote { + static fromJSON(obj: Jsonify | undefined): SecureNote | undefined { if (obj == null) { - return null; + return undefined; } - return Object.assign(new SecureNote(), obj); + const secureNote = new SecureNote(); + secureNote.type = obj.type; + return secureNote; } /** @@ -59,7 +54,7 @@ export class SecureNote extends Domain { * Maps an SDK SecureNote object to a SecureNote * @param obj - The SDK SecureNote object */ - static fromSdkSecureNote(obj: SdkSecureNote): SecureNote | undefined { + static fromSdkSecureNote(obj?: SdkSecureNote): SecureNote | undefined { if (obj == null) { return undefined; } diff --git a/libs/common/src/vault/models/domain/ssh-key.spec.ts b/libs/common/src/vault/models/domain/ssh-key.spec.ts index 6576d1a41e9..38228e54a4a 100644 --- a/libs/common/src/vault/models/domain/ssh-key.spec.ts +++ b/libs/common/src/vault/models/domain/ssh-key.spec.ts @@ -1,3 +1,5 @@ +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; + import { mockEnc } from "../../../../spec"; import { SshKeyApi } from "../api/ssh-key.api"; import { SshKeyData } from "../data/ssh-key.data"; @@ -31,11 +33,10 @@ describe("Sshkey", () => { const data = new SshKeyData(); const sshKey = new SshKey(data); - expect(sshKey).toEqual({ - privateKey: null, - publicKey: null, - keyFingerprint: null, - }); + expect(sshKey).toBeInstanceOf(SshKey); + expect(sshKey.privateKey).toBeInstanceOf(EncString); + expect(sshKey.publicKey).toBeInstanceOf(EncString); + expect(sshKey.keyFingerprint).toBeInstanceOf(EncString); }); it("toSshKeyData", () => { @@ -60,8 +61,8 @@ describe("Sshkey", () => { }); describe("fromJSON", () => { - it("returns null if object is null", () => { - expect(SshKey.fromJSON(null)).toBeNull(); + it("returns undefined if object is null", () => { + expect(SshKey.fromJSON(null)).toBeUndefined(); }); }); diff --git a/libs/common/src/vault/models/domain/ssh-key.ts b/libs/common/src/vault/models/domain/ssh-key.ts index ab1685955a3..a7028321a44 100644 --- a/libs/common/src/vault/models/domain/ssh-key.ts +++ b/libs/common/src/vault/models/domain/ssh-key.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Jsonify } from "type-fest"; import { SshKey as SdkSshKey } from "@bitwarden/sdk-internal"; @@ -11,9 +9,9 @@ import { SshKeyData } from "../data/ssh-key.data"; import { SshKeyView } from "../view/ssh-key.view"; export class SshKey extends Domain { - privateKey: EncString; - publicKey: EncString; - keyFingerprint: EncString; + privateKey!: EncString; + publicKey!: EncString; + keyFingerprint!: EncString; constructor(obj?: SshKeyData) { super(); @@ -21,20 +19,13 @@ export class SshKey extends Domain { return; } - this.buildDomainModel( - this, - obj, - { - privateKey: null, - publicKey: null, - keyFingerprint: null, - }, - [], - ); + this.privateKey = new EncString(obj.privateKey); + this.publicKey = new EncString(obj.publicKey); + this.keyFingerprint = new EncString(obj.keyFingerprint); } decrypt( - orgId: string, + orgId: string | undefined, context = "No Cipher Context", encKey?: SymmetricCryptoKey, ): Promise { @@ -42,7 +33,7 @@ export class SshKey extends Domain { this, new SshKeyView(), ["privateKey", "publicKey", "keyFingerprint"], - orgId, + orgId ?? null, encKey, "DomainType: SshKey; " + context, ); @@ -58,19 +49,17 @@ export class SshKey extends Domain { return c; } - static fromJSON(obj: Partial>): SshKey { + static fromJSON(obj: Jsonify | undefined): SshKey | undefined { if (obj == null) { - return null; + return undefined; } - const privateKey = EncString.fromJSON(obj.privateKey); - const publicKey = EncString.fromJSON(obj.publicKey); - const keyFingerprint = EncString.fromJSON(obj.keyFingerprint); - return Object.assign(new SshKey(), obj, { - privateKey, - publicKey, - keyFingerprint, - }); + const sshKey = new SshKey(); + sshKey.privateKey = EncString.fromJSON(obj.privateKey); + sshKey.publicKey = EncString.fromJSON(obj.publicKey); + sshKey.keyFingerprint = EncString.fromJSON(obj.keyFingerprint); + + return sshKey; } /** @@ -90,7 +79,7 @@ export class SshKey extends Domain { * Maps an SDK SshKey object to a SshKey * @param obj - The SDK SshKey object */ - static fromSdkSshKey(obj: SdkSshKey): SshKey | undefined { + static fromSdkSshKey(obj?: SdkSshKey): SshKey | undefined { if (obj == null) { return undefined; } diff --git a/libs/common/src/vault/models/request/cipher-partial.request.ts b/libs/common/src/vault/models/request/cipher-partial.request.ts index 6037dff6cb2..a50ea10d0cb 100644 --- a/libs/common/src/vault/models/request/cipher-partial.request.ts +++ b/libs/common/src/vault/models/request/cipher-partial.request.ts @@ -1,7 +1,7 @@ import { Cipher } from "../domain/cipher"; export class CipherPartialRequest { - folderId: string; + folderId?: string; favorite: boolean; constructor(cipher: Cipher) { diff --git a/libs/common/src/vault/models/view/identity.view.ts b/libs/common/src/vault/models/view/identity.view.ts index 5fb0d1acba5..dca54fa04e8 100644 --- a/libs/common/src/vault/models/view/identity.view.ts +++ b/libs/common/src/vault/models/view/identity.view.ts @@ -23,7 +23,7 @@ export class IdentityView extends ItemView implements SdkIdentityView { city: string | undefined; @linkedFieldOption(LinkedId.State, { sortPosition: 16, i18nKey: "stateProvince" }) state: string | undefined; - @linkedFieldOption(LinkedId.PostalCode, { sortPosition: 17, i18nKey: "zipPostalCode" }) + @linkedFieldOption(LinkedId.PostalCode, { sortPosition: 17, i18nKey: "zipPostalCodeLabel" }) postalCode: string | undefined; @linkedFieldOption(LinkedId.Country, { sortPosition: 18 }) country: string | undefined; diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 52c83c5a104..efe7bc2b89b 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -869,13 +869,14 @@ export class CipherService implements CipherServiceAbstraction { response = await this.apiService.postCipherAdmin(request); const data = new CipherData(response, cipher.collectionIds); return new Cipher(data); - } else if (cipher.collectionIds != null) { + } else if (cipher.collectionIds != null && cipher.collectionIds.length > 0) { const request = new CipherCreateRequest({ cipher, encryptedFor }); response = await this.apiService.postCipherCreate(request); } else { const request = new CipherRequest({ cipher, encryptedFor }); response = await this.apiService.postCipher(request); } + cipher.id = response.id; const data = new CipherData(response, cipher.collectionIds); 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/common/src/vault/utils/domain-utils.ts b/libs/common/src/vault/utils/domain-utils.ts new file mode 100644 index 00000000000..ee071b29ec3 --- /dev/null +++ b/libs/common/src/vault/utils/domain-utils.ts @@ -0,0 +1,27 @@ +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { EncString as SdkEncString } from "@bitwarden/sdk-internal"; + +/** + * Converts a string value to an EncString, handling null/undefined gracefully. + * + * @param value - The string value to convert, or undefined + * @returns An EncString instance if value is defined, otherwise undefined + * + */ +export const conditionalEncString = (value?: string): EncString | undefined => { + return value != null ? new EncString(value) : undefined; +}; + +/** + * Converts an EncString representation (from JSON or SDK) to a domain EncString instance. + * Handles both serialized JSON representations and SDK EncString objects. + * + * @param value - The EncString representation (string, object, or SdkEncString), or undefined + * @returns A domain EncString instance if value is defined, otherwise undefined + * + */ +export const encStringFrom = ( + value?: T, +): EncString | undefined => { + return value != null ? EncString.fromJSON(value) : undefined; +}; diff --git a/libs/components/src/anon-layout/anon-layout.component.html b/libs/components/src/anon-layout/anon-layout.component.html index f88bdd3f920..15f7d107542 100644 --- a/libs/components/src/anon-layout/anon-layout.component.html +++ b/libs/components/src/anon-layout/anon-layout.component.html @@ -48,11 +48,11 @@
} @else { -
-
+ } diff --git a/libs/components/src/anon-layout/anon-layout.component.ts b/libs/components/src/anon-layout/anon-layout.component.ts index 596a54f8825..e6572a0c3c1 100644 --- a/libs/components/src/anon-layout/anon-layout.component.ts +++ b/libs/components/src/anon-layout/anon-layout.component.ts @@ -21,6 +21,7 @@ import { ClientType } from "@bitwarden/common/enums"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { BaseCardComponent } from "../card"; import { IconModule } from "../icon"; import { SharedModule } from "../shared"; import { TypographyModule } from "../typography"; @@ -32,7 +33,14 @@ export type AnonLayoutMaxWidth = "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl"; @Component({ selector: "auth-anon-layout", templateUrl: "./anon-layout.component.html", - imports: [IconModule, CommonModule, TypographyModule, SharedModule, RouterModule], + imports: [ + IconModule, + CommonModule, + TypographyModule, + SharedModule, + RouterModule, + BaseCardComponent, + ], }) export class AnonLayoutComponent implements OnInit, OnChanges { @HostBinding("class") 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 new file mode 100644 index 00000000000..8c4dd80f2d1 --- /dev/null +++ b/libs/components/src/card/base-card/base-card.component.ts @@ -0,0 +1,16 @@ +import { Component } from "@angular/core"; + +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: ``, + hostDirectives: [BaseCardDirective], +}) +export class BaseCardComponent {} diff --git a/libs/components/src/card/base-card/base-card.directive.ts b/libs/components/src/card/base-card/base-card.directive.ts new file mode 100644 index 00000000000..7c6ec2b3b2f --- /dev/null +++ b/libs/components/src/card/base-card/base-card.directive.ts @@ -0,0 +1,9 @@ +import { Directive } from "@angular/core"; + +@Directive({ + host: { + class: + "tw-box-border tw-block tw-bg-background tw-text-main tw-border tw-border-solid tw-border-secondary-100 tw-shadow tw-rounded-xl", + }, +}) +export class BaseCardDirective {} diff --git a/libs/components/src/card/base-card/base-card.mdx b/libs/components/src/card/base-card/base-card.mdx new file mode 100644 index 00000000000..df326462906 --- /dev/null +++ b/libs/components/src/card/base-card/base-card.mdx @@ -0,0 +1,23 @@ +import { Meta, Primary, Controls, Canvas, Title, Description } from "@storybook/addon-docs"; + +import * as stories from "./base-card.stories"; + + + +```ts +import { BaseCardComponent } from "@bitwarden/components"; +``` + + +<Description /> + +<Canvas of={stories.Default} /> + +## BaseCardDirective + +There is also a `BaseCardDirective` available for use as a hostDirective if need be. But, most +likely using `<bit-base-card>` in your template will do. + +```ts +import { BaseCardDirective } from "@bitwarden/components"; +``` diff --git a/libs/components/src/card/base-card/base-card.stories.ts b/libs/components/src/card/base-card/base-card.stories.ts new file mode 100644 index 00000000000..bae07dd1468 --- /dev/null +++ b/libs/components/src/card/base-card/base-card.stories.ts @@ -0,0 +1,41 @@ +import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; + +import { AnchorLinkDirective } from "../../link"; +import { TypographyModule } from "../../typography"; + +import { BaseCardComponent } from "./base-card.component"; + +export default { + title: "Component Library/Cards/BaseCard", + component: BaseCardComponent, + decorators: [ + moduleMetadata({ + imports: [AnchorLinkDirective, TypographyModule], + }), + ], + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=16329-28355&t=b5tDKylm5sWm2yKo-4", + }, + }, +} as Meta; + +type Story = StoryObj<BaseCardComponent>; + +/** Cards are presentational containers. */ +export const Default: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + <bit-base-card> + <p bitTypography="body1" class="!tw-mb-0"> + The <code><bit-base-card></code> component is a container that applies our standard border and box-shadow. In most cases, <code><bit-card></code> should be used for consistency + </p> + <p bitTypography="body1" class="!tw-mb-0"> + <code><bit-base-card></code> is used in the <a bitLink href="/?path=/story/web-reports-card--enabled">ReportCardComponent</a> and <strong>IntegrationsCardComponent</strong> since they have custom padding requirements + </p> + </bit-base-card> + `, + }), +}; diff --git a/libs/components/src/card/base-card/index.ts b/libs/components/src/card/base-card/index.ts new file mode 100644 index 00000000000..186f2e68f24 --- /dev/null +++ b/libs/components/src/card/base-card/index.ts @@ -0,0 +1,2 @@ +export * from "./base-card.component"; +export * from "./base-card.directive"; diff --git a/libs/components/src/card/card-content.component.ts b/libs/components/src/card/card-content.component.ts new file mode 100644 index 00000000000..650a2665475 --- /dev/null +++ b/libs/components/src/card/card-content.component.ts @@ -0,0 +1,9 @@ +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: `<div class="tw-p-4 [@media(min-width:650px)]:tw-p-6"><ng-content></ng-content></div>`, +}) +export class CardContentComponent {} diff --git a/libs/components/src/card/card.component.ts b/libs/components/src/card/card.component.ts index d7e36d1ea9e..9cca973f003 100644 --- a/libs/components/src/card/card.component.ts +++ b/libs/components/src/card/card.component.ts @@ -1,12 +1,14 @@ import { ChangeDetectionStrategy, Component } from "@angular/core"; +import { BaseCardDirective } from "./base-card/base-card.directive"; + @Component({ selector: "bit-card", template: `<ng-content></ng-content>`, changeDetection: ChangeDetectionStrategy.OnPush, host: { - class: - "tw-box-border tw-block tw-bg-background tw-text-main tw-border-solid tw-border-b tw-border-0 tw-border-b-secondary-300 [&:not(bit-layout_*)]:tw-rounded-lg [&:not(bit-layout_*)]:tw-border-b-shadow tw-py-4 bit-compact:tw-py-3 tw-px-3 bit-compact:tw-px-2", + class: "tw-p-4 [@media(min-width:650px)]:tw-p-6", }, + hostDirectives: [BaseCardDirective], }) export class CardComponent {} diff --git a/libs/components/src/card/card.stories.ts b/libs/components/src/card/card.stories.ts index 411cc8e83cc..77faceb8eb7 100644 --- a/libs/components/src/card/card.stories.ts +++ b/libs/components/src/card/card.stories.ts @@ -11,7 +11,7 @@ import { I18nMockService } from "../utils/i18n-mock.service"; import { CardComponent } from "./card.component"; export default { - title: "Component Library/Card", + title: "Component Library/Cards/Card", component: CardComponent, decorators: [ moduleMetadata({ @@ -84,16 +84,3 @@ export const WithinSections: Story = { `, }), }; - -export const WithoutBorderRadius: Story = { - render: (args) => ({ - props: args, - template: /*html*/ ` - <bit-layout> - <bit-card> - <p bitTypography="body1" class="!tw-mb-0">Cards used in <code class="tw-text-danger-700">bit-layout</code> will not have a border radius</p> - </bit-card> - </bit-layout> - `, - }), -}; diff --git a/libs/components/src/card/index.ts b/libs/components/src/card/index.ts index 8151bac4c8b..1027f9b1fe2 100644 --- a/libs/components/src/card/index.ts +++ b/libs/components/src/card/index.ts @@ -1 +1,3 @@ +export * from "./base-card"; export * from "./card.component"; +export * from "./card-content.component"; diff --git a/libs/components/src/form-field/form-field.component.html b/libs/components/src/form-field/form-field.component.html index c2c92104727..a4af25a2492 100644 --- a/libs/components/src/form-field/form-field.component.html +++ b/libs/components/src/form-field/form-field.component.html @@ -97,7 +97,7 @@ <ng-container *ngTemplateOutlet="prefixContent"></ng-container> </div> <div - class="tw-w-full tw-pb-0 tw-relative [&>*]:tw-p-0 [&>*::selection]:tw-bg-primary-700 [&>*::selection]:tw-text-contrast" + class="tw-w-full tw-min-w-0 tw-pb-0 tw-relative [&>*]:tw-p-0 [&>*::selection]:tw-bg-primary-700 [&>*::selection]:tw-text-contrast" data-default-content > <ng-container *ngTemplateOutlet="defaultContent"></ng-container> 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<IconButtonSize, string[]> = { */ "[attr.bitIconButton]": "icon()", }, - hostDirectives: [AriaDisableDirective], + hostDirectives: [ + AriaDisableDirective, + { directive: TooltipDirective, inputs: ["tooltipPosition"] }, + ], }) export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableElement { readonly icon = model.required<string>({ alias: "bitIconButton" }); @@ -109,6 +113,9 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE readonly size = model<IconButtonSize>("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()" > <ng-template #button> <button @@ -18,7 +19,6 @@ [buttonType]="'nav-contrast'" (click)="toggle($event)" size="small" - aria-haspopup="true" [attr.aria-expanded]="open().toString()" [attr.aria-controls]="contentId" [label]="['toggleCollapse' | i18n, text()].join(' ')" @@ -30,7 +30,7 @@ </ng-container> </bit-nav-item> <!-- [attr.aria-controls] of the above button expects a unique ID on the controlled element --> - @if (sideNavService.open$ | async) { + @if (sideNavOpen()) { @if (open()) { <div [attr.id]="contentId" diff --git a/libs/components/src/navigation/nav-group.component.ts b/libs/components/src/navigation/nav-group.component.ts index e3bb02bb75a..3408af3d734 100644 --- a/libs/components/src/navigation/nav-group.component.ts +++ b/libs/components/src/navigation/nav-group.component.ts @@ -9,7 +9,10 @@ import { input, model, contentChildren, + computed, } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; +import { RouterLinkActive } from "@angular/router"; import { I18nPipe } from "@bitwarden/ui-common"; @@ -33,10 +36,33 @@ import { SideNavService } from "./side-nav.service"; export class NavGroupComponent extends NavBaseComponent { readonly nestedNavComponents = contentChildren(NavBaseComponent, { descendants: true }); + readonly sideNavOpen = toSignal(this.sideNavService.open$); + + readonly sideNavAndGroupOpen = computed(() => { + return this.open() && this.sideNavOpen(); + }); + /** When the side nav is open, the parent nav item should not show active styles when open. */ - protected get parentHideActiveStyles(): boolean { - return this.hideActiveStyles() || (this.open() && this.sideNavService.open); - } + readonly parentHideActiveStyles = computed(() => { + return this.hideActiveStyles() || this.sideNavAndGroupOpen(); + }); + + /** + * Allow overriding of the RouterLink['ariaCurrentWhenActive'] property. + * + * By default, assuming that the nav group navigates to its first child page instead of its + * own page, the nav group will be `current` when the side nav is collapsed or the nav group + * is collapsed (since child pages don't show in either collapsed view) and not `current` + * when the side nav and nav group are open (since the child page will show as `current`). + * + * If the nav group navigates to its own page, use this property to always set it to announce + * as `current` by passing in `"page"`. + */ + readonly ariaCurrentWhenActive = input<RouterLinkActive["ariaCurrentWhenActive"]>(); + + readonly ariaCurrent = computed(() => { + return this.ariaCurrentWhenActive() ?? (this.sideNavAndGroupOpen() ? undefined : "page"); + }); /** * UID for `[attr.aria-controls]` diff --git a/libs/components/src/navigation/nav-item.component.html b/libs/components/src/navigation/nav-item.component.html index f7d55a33362..37a5d82aa1b 100644 --- a/libs/components/src/navigation/nav-item.component.html +++ b/libs/components/src/navigation/nav-item.component.html @@ -11,7 +11,7 @@ ]" > <div class="tw-relative tw-flex tw-items-center tw-h-full"> - <ng-container *ngIf="route; then isAnchor; else isButton"></ng-container> + <ng-container *ngIf="route(); then isAnchor; else isButton"></ng-container> <!-- Main content of `NavItem` --> <ng-template #anchorAndButtonContent> @@ -31,7 +31,7 @@ </div> </ng-template> - <!-- Show if a value was passed to `this.to` --> + <!-- Show if a value was passed to `this.route` --> <ng-template #isAnchor> <!-- The `data-fvw` attribute passes focus to `this.focusVisibleWithin$` --> <!-- The following `class` field should match the `#isButton` class field below --> @@ -43,7 +43,7 @@ [attr.aria-label]="ariaLabel() || text()" routerLinkActive [routerLinkActiveOptions]="routerLinkActiveOptions()" - [ariaCurrentWhenActive]="'page'" + [ariaCurrentWhenActive]="ariaCurrentWhenActive()" (isActiveChange)="setIsActive($event)" (click)="mainContentClicked.emit()" > @@ -51,12 +51,13 @@ </a> </ng-template> - <!-- Show if `this.to` is falsy --> + <!-- Show if `this.route` is falsy --> <ng-template #isButton> <!-- Class field should match `#isAnchor` class field above --> <button type="button" - class="tw-size-full tw-px-4 tw-pe-3 tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none [&_i]:tw-leading-[1.5rem]" + class="tw-size-full tw-px-4 tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none [&_i]:tw-leading-[1.5rem]" + [ngClass]="open ? 'tw-pe-3' : 'tw-pe-4'" data-fvw (click)="mainContentClicked.emit()" > diff --git a/libs/components/src/navigation/nav-item.component.ts b/libs/components/src/navigation/nav-item.component.ts index 5b5709ebebb..de757f1aec4 100644 --- a/libs/components/src/navigation/nav-item.component.ts +++ b/libs/components/src/navigation/nav-item.component.ts @@ -1,6 +1,6 @@ import { CommonModule } from "@angular/common"; import { Component, HostListener, Optional, input } from "@angular/core"; -import { RouterModule } from "@angular/router"; +import { RouterLinkActive, RouterModule } from "@angular/router"; import { BehaviorSubject, map } from "rxjs"; import { IconButtonModule } from "../icon-button"; @@ -39,6 +39,14 @@ export class NavItemComponent extends NavBaseComponent { return this.forceActiveStyles() || (this._isActive && !this.hideActiveStyles()); } + /** + * Allow overriding of the RouterLink['ariaCurrentWhenActive'] property. + * + * Useful for situations like nav-groups that navigate to their first child page and should + * not be marked `current` while the child page is marked as `current` + */ + readonly ariaCurrentWhenActive = input<RouterLinkActive["ariaCurrentWhenActive"]>("page"); + /** * The design spec calls for the an outline to wrap the entire element when the template's * anchor/button has :focus-visible. Usually, we would use :focus-within for this. However, that diff --git a/libs/components/src/tooltip/tooltip.component.html b/libs/components/src/tooltip/tooltip.component.html index 4d354fc2765..ce9f1ceeffe 100644 --- a/libs/components/src/tooltip/tooltip.component.html +++ b/libs/components/src/tooltip/tooltip.component.html @@ -1,9 +1,11 @@ -<div - class="bit-tooltip-container" - [attr.data-position]="tooltipData.tooltipPosition()" - [attr.data-visible]="tooltipData.isVisible()" -> - <div role="tooltip" class="bit-tooltip"> - <ng-content>{{ tooltipData.content() }}</ng-content> +@if (tooltipData.content()) { + <div + class="bit-tooltip-container" + [attr.data-position]="tooltipData.tooltipPosition()" + [attr.data-visible]="tooltipData.isVisible()" + > + <div role="tooltip" class="bit-tooltip" [id]="tooltipData.id()"> + <ng-content>{{ tooltipData.content() }}</ng-content> + </div> </div> -</div> +} diff --git a/libs/components/src/tooltip/tooltip.component.ts b/libs/components/src/tooltip/tooltip.component.ts index 34c67015004..79e2dfd7973 100644 --- a/libs/components/src/tooltip/tooltip.component.ts +++ b/libs/components/src/tooltip/tooltip.component.ts @@ -15,6 +15,7 @@ type TooltipData = { content: Signal<string>; isVisible: Signal<boolean>; tooltipPosition: Signal<TooltipPosition>; + id: Signal<string>; }; export const TOOLTIP_DATA = new InjectionToken<TooltipData>("TOOLTIP_DATA"); diff --git a/libs/components/src/tooltip/tooltip.directive.ts b/libs/components/src/tooltip/tooltip.directive.ts index b2c1621d710..bcf9fc5e174 100644 --- a/libs/components/src/tooltip/tooltip.directive.ts +++ b/libs/components/src/tooltip/tooltip.directive.ts @@ -8,8 +8,9 @@ import { ElementRef, Injector, input, - effect, signal, + model, + computed, } from "@angular/core"; import { TooltipPositionIdentifier, tooltipPositions } from "./tooltip-positions"; @@ -26,30 +27,39 @@ import { TooltipComponent, TOOLTIP_DATA } from "./tooltip.component"; "(mouseleave)": "hideTooltip()", "(focus)": "showTooltip()", "(blur)": "hideTooltip()", + "[attr.aria-describedby]": "resolvedDescribedByIds()", }, }) export class TooltipDirective implements OnInit { + private static nextId = 0; /** * The value of this input is forwarded to the tooltip.component to render */ - readonly bitTooltip = input.required<string>(); + readonly tooltipContent = model("", { alias: "bitTooltip" }); /** * The value of this input is forwarded to the tooltip.component to set its position explicitly. * @default "above-center" */ readonly tooltipPosition = input<TooltipPositionIdentifier>("above-center"); + /** + * Input so the consumer can choose to add the tooltip id to the aria-describedby attribute of the host element. + */ + readonly addTooltipToDescribedby = input<boolean>(false); + private readonly isVisible = signal(false); private overlayRef: OverlayRef | undefined; - private elementRef = inject(ElementRef); + private elementRef = inject<ElementRef<HTMLElement>>(ElementRef); private overlay = inject(Overlay); private viewContainerRef = inject(ViewContainerRef); - private injector = inject(Injector); private positionStrategy = this.overlay .position() .flexibleConnectedTo(this.elementRef) .withFlexibleDimensions(false) .withPush(true); + private tooltipId = `bit-tooltip-${TooltipDirective.nextId++}`; + private currentDescribedByIds = + this.elementRef.nativeElement.getAttribute("aria-describedby") || null; private tooltipPortal = new ComponentPortal( TooltipComponent, @@ -59,23 +69,50 @@ export class TooltipDirective implements OnInit { { provide: TOOLTIP_DATA, useValue: { - content: this.bitTooltip, + content: this.tooltipContent, isVisible: this.isVisible, tooltipPosition: this.tooltipPosition, + id: signal(this.tooltipId), }, }, ], }), ); + private destroyTooltip = () => { + this.overlayRef?.dispose(); + this.overlayRef = undefined; + this.isVisible.set(false); + }; + private showTooltip = () => { + if (!this.overlayRef) { + this.overlayRef = this.overlay.create({ + ...this.defaultPopoverConfig, + positionStrategy: this.positionStrategy, + }); + + this.overlayRef.attach(this.tooltipPortal); + } this.isVisible.set(true); }; private hideTooltip = () => { - this.isVisible.set(false); + this.destroyTooltip(); }; + private readonly resolvedDescribedByIds = computed(() => { + if (this.addTooltipToDescribedby()) { + if (this.currentDescribedByIds) { + return `${this.currentDescribedByIds || ""} ${this.tooltipId}`; + } else { + return this.tooltipId; + } + } else { + return this.currentDescribedByIds; + } + }); + private computePositions(tooltipPosition: TooltipPositionIdentifier) { const chosenPosition = tooltipPositions.find((position) => position.id === tooltipPosition); @@ -91,20 +128,5 @@ export class TooltipDirective implements OnInit { ngOnInit() { this.positionStrategy.withPositions(this.computePositions(this.tooltipPosition())); - - this.overlayRef = this.overlay.create({ - ...this.defaultPopoverConfig, - positionStrategy: this.positionStrategy, - }); - - this.overlayRef.attach(this.tooltipPortal); - - effect( - () => { - this.positionStrategy.withPositions(this.computePositions(this.tooltipPosition())); - this.overlayRef?.updatePosition(); - }, - { injector: this.injector }, - ); } } diff --git a/libs/components/src/tooltip/tooltip.mdx b/libs/components/src/tooltip/tooltip.mdx index 4b6f10d97f8..13e159c98eb 100644 --- a/libs/components/src/tooltip/tooltip.mdx +++ b/libs/components/src/tooltip/tooltip.mdx @@ -11,7 +11,20 @@ import { TooltipDirective } from "@bitwarden/components"; <Title /> <Description /> -NOTE: The `TooltipComponent` can't be used on its own. It must be applied via the `TooltipDirective` +### Tooltip usage + +The `TooltipComponent` can't be used on its own. It must be applied via the `TooltipDirective`. + +The `IconButtonComponent` will automatically apply a tooltip based on the component's `label` input. + +#### Adding the tooltip to the host element's `aria-describedby` list + +The `addTooltipToDescribedby="true"` model input can be used to add the tooltip id to the list of +the host element's `aria-describedby` element IDs. + +NOTE: This behavior is not always necessary and could be redundant if the host element's aria +attributes already convey the same message as the tooltip. Use only when the tooltip is extra, +non-essential contextual information. <Primary /> <Controls /> @@ -29,3 +42,7 @@ NOTE: The `TooltipComponent` can't be used on its own. It must be applied via th ### On disabled element <Canvas of={stories.OnDisabledButton} /> + +### On a Button + +<Canvas of={stories.OnNonIconButton} /> diff --git a/libs/components/src/tooltip/tooltip.spec.ts b/libs/components/src/tooltip/tooltip.spec.ts index b6a49acbc77..a88424de3bb 100644 --- a/libs/components/src/tooltip/tooltip.spec.ts +++ b/libs/components/src/tooltip/tooltip.spec.ts @@ -59,7 +59,14 @@ describe("TooltipDirective (visibility only)", () => { }; const overlayRefStub: OverlayRefStub = { - attach: jest.fn(() => ({})), + attach: jest.fn(() => ({ + changeDetectorRef: { detectChanges: jest.fn() }, + location: { + nativeElement: { + querySelector: jest.fn().mockReturnValue({ id: "tip-123" }), + }, + }, + })), updatePosition: jest.fn(), }; diff --git a/libs/components/src/tooltip/tooltip.stories.ts b/libs/components/src/tooltip/tooltip.stories.ts index 8ea3f52f913..73dad5801f3 100644 --- a/libs/components/src/tooltip/tooltip.stories.ts +++ b/libs/components/src/tooltip/tooltip.stories.ts @@ -72,7 +72,6 @@ type Story = StoryObj<TooltipDirective>; export const Default: Story = { args: { - bitTooltip: "This is a tooltip", tooltipPosition: "above-center", }, render: (args) => ({ @@ -81,6 +80,7 @@ export const Default: Story = { <div class="tw-p-4"> <button bitIconButton="bwi-ellipsis-v" + label="Your tooltip content here" ${formatArgsForCodeSnippet<TooltipDirective>(args)} > Button label here @@ -98,26 +98,29 @@ export const Default: Story = { export const AllPositions: Story = { render: () => ({ + parameters: { + chromatic: { disableSnapshot: true }, + }, template: ` <div class="tw-p-16 tw-grid tw-grid-cols-2 tw-gap-8 tw-place-items-center"> <button bitIconButton="bwi-angle-up" - bitTooltip="Top tooltip" + label="Top tooltip" tooltipPosition="above-center" ></button> <button bitIconButton="bwi-angle-right" - bitTooltip="Right tooltip" + label="Right tooltip" tooltipPosition="right-center" ></button> <button bitIconButton="bwi-angle-left" - bitTooltip="Left tooltip" + label="Left tooltip" tooltipPosition="left-center" ></button> <button bitIconButton="bwi-angle-down" - bitTooltip="Bottom tooltip" + label="Bottom tooltip" tooltipPosition="below-center" ></button> </div> @@ -127,11 +130,14 @@ export const AllPositions: Story = { export const LongContent: Story = { render: () => ({ + parameters: { + chromatic: { disableSnapshot: true }, + }, template: ` <div class="tw-p-16 tw-flex tw-items-center tw-justify-center"> <button bitIconButton="bwi-ellipsis-v" - bitTooltip="This is a very long tooltip that will wrap to multiple lines to demonstrate how the tooltip handles long content. This is not recommended for usability." + label="This is a very long tooltip that will wrap to multiple lines to demonstrate how the tooltip handles long content. This is not recommended for usability." ></button> </div> `, @@ -140,14 +146,34 @@ export const LongContent: Story = { export const OnDisabledButton: Story = { render: () => ({ + parameters: { + chromatic: { disableSnapshot: true }, + }, template: ` <div class="tw-p-16 tw-flex tw-items-center tw-justify-center"> <button bitIconButton="bwi-ellipsis-v" - bitTooltip="Tooltip on disabled button" + label="Tooltip on disabled button" [disabled]="true" ></button> </div> `, }), }; + +export const OnNonIconButton: Story = { + render: () => ({ + parameters: { + chromatic: { disableSnapshot: true }, + }, + template: ` + <div class="tw-p-16 tw-flex tw-items-center tw-justify-center"> + <button + bitButton + addTooltipToDescribedby="true" + bitTooltip="Some additional tooltip text to describe the button" + >Button label</button> + </div> + `, + }), +}; diff --git a/libs/dirt/card/src/card.component.html b/libs/dirt/card/src/card.component.html index 3fd9372087c..8688cd8fd2c 100644 --- a/libs/dirt/card/src/card.component.html +++ b/libs/dirt/card/src/card.component.html @@ -1,7 +1,9 @@ -<div class="tw-flex-col"> - <span bitTypography="body2" class="tw-flex tw-text-muted">{{ title }}</span> - <div class="tw-flex tw-items-baseline tw-gap-2"> - <span bitTypography="h1">{{ value }}</span> - <span bitTypography="body2">{{ "cardMetrics" | i18n: maxValue }}</span> +<bit-card> + <div class="tw-flex tw-flex-col tw-gap-1.5"> + <span bitTypography="body2" class="tw-flex tw-text-muted">{{ title }}</span> + <div class="tw-flex tw-items-baseline tw-gap-2"> + <span bitTypography="h1" class="!tw-mb-0">{{ value }}</span> + <span bitTypography="body2">{{ "cardMetrics" | i18n: maxValue }}</span> + </div> </div> -</div> +</bit-card> diff --git a/libs/dirt/card/src/card.component.ts b/libs/dirt/card/src/card.component.ts index f9899125dbd..089115fc2bf 100644 --- a/libs/dirt/card/src/card.component.ts +++ b/libs/dirt/card/src/card.component.ts @@ -4,28 +4,32 @@ import { CommonModule } from "@angular/common"; import { Component, Input } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { TypographyModule } from "@bitwarden/components"; +import { TypographyModule, CardComponent as BitCardComponent } from "@bitwarden/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: "dirt-card", templateUrl: "./card.component.html", - imports: [CommonModule, TypographyModule, JslibModule], - host: { - class: - "tw-box-border tw-bg-background tw-block tw-text-main tw-border-solid tw-border tw-border-secondary-300 tw-border [&:not(bit-layout_*)]:tw-rounded-lg tw-rounded-lg tw-p-6", - }, + imports: [CommonModule, TypographyModule, JslibModule, BitCardComponent], }) export class CardComponent { /** * The title of the card */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() title: string; /** * The current value of the card as emphasized text */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() value: number; /** * The maximum value of the card */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() maxValue: number; } diff --git a/libs/importer/src/services/default-import-metadata.service.ts b/libs/importer/src/services/default-import-metadata.service.ts index 05b8472869d..e9c526fb6c0 100644 --- a/libs/importer/src/services/default-import-metadata.service.ts +++ b/libs/importer/src/services/default-import-metadata.service.ts @@ -61,27 +61,28 @@ export class DefaultImportMetadataService implements ImportMetadataServiceAbstra importers: ImportersMetadata, type: ImportType, client: ClientType, - enabled: boolean, + withABESupport: boolean, ): DataLoader[] | undefined { let loaders = availableLoaders(importers, type, client); - let includeABE = false; - if (enabled && (type === "bravecsv" || type === "chromecsv" || type === "edgecsv")) { + if (withABESupport) { + return loaders; + } + + // Special handling for Brave, Chrome, and Edge CSV imports on Windows Desktop + if (type === "bravecsv" || type === "chromecsv" || type === "edgecsv") { try { const device = this.system.environment.getDevice(); const isWindowsDesktop = device === DeviceType.WindowsDesktop; if (isWindowsDesktop) { - includeABE = true; + // Exclude the Chromium loader if on Windows Desktop without ABE support + loaders = loaders?.filter((loader) => loader !== Loader.chromium); } } catch { - includeABE = true; + loaders = loaders?.filter((loader) => loader !== Loader.chromium); } } - // If the browser is unsupported, remove the chromium loader - if (!includeABE) { - loaders = loaders?.filter((loader) => loader !== Loader.chromium); - } return loaders; } } diff --git a/libs/importer/src/services/import-metadata.service.spec.ts b/libs/importer/src/services/import-metadata.service.spec.ts index 908ce6ad476..e16965a69f8 100644 --- a/libs/importer/src/services/import-metadata.service.spec.ts +++ b/libs/importer/src/services/import-metadata.service.spec.ts @@ -55,6 +55,25 @@ describe("ImportMetadataService", () => { // Recreate the service with the updated mocks for logging tests sut = new DefaultImportMetadataService(systemServiceProvider); + + // Set up importers to include bravecsv and chromecsv with chromium loader + sut["importers"] = { + chromecsv: { + type: "chromecsv", + loaders: [Loader.file, Loader.chromium], + instructions: Instructions.chromium, + }, + bravecsv: { + type: "bravecsv", + loaders: [Loader.file, Loader.chromium], + instructions: Instructions.chromium, + }, + edgecsv: { + type: "edgecsv", + loaders: [Loader.file, Loader.chromium], + instructions: Instructions.chromium, + }, + } as ImportersMetadata; }); afterEach(() => { @@ -112,6 +131,7 @@ describe("ImportMetadataService", () => { }); it("should update when feature flag changes", async () => { + environment.getDevice.mockReturnValue(DeviceType.WindowsDesktop); const testType: ImportType = "bravecsv"; // Use bravecsv which supports chromium loader const emissions: ImporterMetadata[] = []; @@ -126,13 +146,15 @@ describe("ImportMetadataService", () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(emissions).toHaveLength(2); + // Disable ABE - chromium loader should be excluded expect(emissions[0].loaders).not.toContain(Loader.chromium); - expect(emissions[1].loaders).toContain(Loader.file); + // Enabled ABE - chromium loader should be included + expect(emissions[1].loaders).toContain(Loader.chromium); subscription.unsubscribe(); }); - it("should exclude chromium loader when ABE is disabled but on Windows Desktop", async () => { + it("should exclude chromium loader when ABE is disabled and on Windows Desktop", async () => { environment.getDevice.mockReturnValue(DeviceType.WindowsDesktop); const testType: ImportType = "bravecsv"; // bravecsv supports both file and chromium loaders featureFlagSubject.next(false); @@ -146,10 +168,12 @@ describe("ImportMetadataService", () => { expect(result.loaders).toContain(Loader.file); }); - it("should exclude chromium loader when ABE is enabled but not on Windows Desktop", async () => { - environment.getDevice.mockReturnValue(DeviceType.MacOsDesktop); - const testType: ImportType = "bravecsv"; // bravecsv supports both file and chromium loaders - featureFlagSubject.next(true); + it("should exclude chromium loader when ABE is disabled and getDevice throws error", async () => { + environment.getDevice.mockImplementation(() => { + throw new Error("Device detection failed"); + }); + const testType: ImportType = "bravecsv"; + featureFlagSubject.next(false); const metadataPromise = firstValueFrom(sut.metadata$(typeSubject)); typeSubject.next(testType); @@ -160,17 +184,22 @@ describe("ImportMetadataService", () => { expect(result.loaders).toContain(Loader.file); }); - it("should include chromium loader when ABE is enabled and on Windows Desktop", async () => { - // Set up importers to include bravecsv with chromium loader - sut["importers"] = { - bravecsv: { - type: "bravecsv", - loaders: [Loader.file, Loader.chromium], - instructions: Instructions.chromium, - }, - } as ImportersMetadata; + it("should include chromium loader when ABE is disabled and not on Windows Desktop", async () => { + environment.getDevice.mockReturnValue(DeviceType.MacOsDesktop); + const testType: ImportType = "bravecsv"; // bravecsv supports both file and chromium loaders + featureFlagSubject.next(false); - environment.getDevice.mockReturnValue(DeviceType.WindowsDesktop); + const metadataPromise = firstValueFrom(sut.metadata$(typeSubject)); + typeSubject.next(testType); + + const result = await metadataPromise; + + expect(result.loaders).toContain(Loader.chromium); + expect(result.loaders).toContain(Loader.file); + }); + + it("should include chromium loader when ABE is enabled regardless of device", async () => { + environment.getDevice.mockReturnValue(DeviceType.MacOsDesktop); const testType: ImportType = "bravecsv"; // bravecsv supports both file and chromium loaders featureFlagSubject.next(true); diff --git a/libs/key-management/src/abstractions/key.service.ts b/libs/key-management/src/abstractions/key.service.ts index abd4dcc1563..7891c9952b2 100644 --- a/libs/key-management/src/abstractions/key.service.ts +++ b/libs/key-management/src/abstractions/key.service.ts @@ -10,7 +10,7 @@ import { import { WrappedSigningKey } from "@bitwarden/common/key-management/types"; import { KeySuffixOptions, HashPurpose } from "@bitwarden/common/platform/enums"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { OrganizationId, ProviderId, UserId } from "@bitwarden/common/types/guid"; import { UserKey, MasterKey, @@ -248,17 +248,19 @@ export abstract class KeyService { /** * Stores the provider keys for a given user. - * @param orgs The provider orgs for which to save the keys from. + * @param providers The provider orgs for which to save the keys from. * @param userId The user id of the user for which to store the keys for. */ - abstract setProviderKeys(orgs: ProfileProviderResponse[], userId: UserId): Promise<void>; + abstract setProviderKeys(providers: ProfileProviderResponse[], userId: UserId): Promise<void>; + /** - * - * @throws Error when providerId is null or no active user - * @param providerId The desired provider - * @returns The provider's symmetric key + * Gets an observable of provider keys for the given user. + * @param userId The user to get provider keys for. + * @return An observable stream of the users providers keys if they are unlocked, or null if the user is not unlocked. + * @throws If an invalid user id is passed in. */ - abstract getProviderKey(providerId: string): Promise<ProviderKey | null>; + abstract providerKeys$(userId: UserId): Observable<Record<ProviderId, ProviderKey> | null>; + /** * Creates a new organization key and encrypts it with the user's public key. * This method can also return Provider keys for creating new Provider users. diff --git a/libs/key-management/src/key.service.spec.ts b/libs/key-management/src/key.service.spec.ts index 0dd9f3603f5..5d5340d4900 100644 --- a/libs/key-management/src/key.service.spec.ts +++ b/libs/key-management/src/key.service.spec.ts @@ -39,7 +39,7 @@ import { FakeSingleUserState, } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; -import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { OrganizationId, ProviderId, UserId } from "@bitwarden/common/types/guid"; import { UserKey, MasterKey, @@ -1314,6 +1314,49 @@ describe("keyService", () => { }); }); + describe("providerKeys$", () => { + let mockUserPrivateKey: Uint8Array; + let mockProviderKeys: Record<ProviderId, ProviderKey>; + + beforeEach(() => { + mockUserPrivateKey = makeStaticByteArray(64, 1); + mockProviderKeys = { + ["provider1" as ProviderId]: makeSymmetricCryptoKey<ProviderKey>(64), + ["provider2" as ProviderId]: makeSymmetricCryptoKey<ProviderKey>(64), + }; + }); + + it("returns null when userPrivateKey is null", async () => { + jest.spyOn(keyService, "userPrivateKey$").mockReturnValue(of(null)); + + const result = await firstValueFrom(keyService.providerKeys$(mockUserId)); + + expect(result).toBeNull(); + }); + + it("returns provider keys when userPrivateKey is available", async () => { + jest.spyOn(keyService, "userPrivateKey$").mockReturnValue(of(mockUserPrivateKey as any)); + jest.spyOn(keyService as any, "providerKeysHelper$").mockReturnValue(of(mockProviderKeys)); + + const result = await firstValueFrom(keyService.providerKeys$(mockUserId)); + + expect(result).toEqual(mockProviderKeys); + expect((keyService as any).providerKeysHelper$).toHaveBeenCalledWith( + mockUserId, + mockUserPrivateKey, + ); + }); + + it("returns null when providerKeysHelper$ returns null", async () => { + jest.spyOn(keyService, "userPrivateKey$").mockReturnValue(of(mockUserPrivateKey as any)); + jest.spyOn(keyService as any, "providerKeysHelper$").mockReturnValue(of(null)); + + const result = await firstValueFrom(keyService.providerKeys$(mockUserId)); + + expect(result).toBeNull(); + }); + }); + describe("makeKeyPair", () => { test.each([null as unknown as SymmetricCryptoKey, undefined as unknown as SymmetricCryptoKey])( "throws when the provided key is %s", diff --git a/libs/key-management/src/key.service.ts b/libs/key-management/src/key.service.ts index fc340410124..032faeaf42e 100644 --- a/libs/key-management/src/key.service.ts +++ b/libs/key-management/src/key.service.ts @@ -426,20 +426,16 @@ export class DefaultKeyService implements KeyServiceAbstraction { }); } - // TODO: Deprecate in favor of observable - async getProviderKey(providerId: ProviderId): Promise<ProviderKey | null> { - if (providerId == null) { - return null; - } + providerKeys$(userId: UserId): Observable<Record<ProviderId, ProviderKey> | null> { + return this.userPrivateKey$(userId).pipe( + switchMap((userPrivateKey) => { + if (userPrivateKey == null) { + return of(null); + } - const activeUserId = await firstValueFrom(this.stateProvider.activeUserId$); - if (activeUserId == null) { - throw new Error("No active user found."); - } - - const providerKeys = await firstValueFrom(this.providerKeys$(activeUserId)); - - return providerKeys?.[providerId] ?? null; + return this.providerKeysHelper$(userId, userPrivateKey); + }), + ); } private async clearProviderKeys(userId: UserId): Promise<void> { @@ -829,18 +825,6 @@ export class DefaultKeyService implements KeyServiceAbstraction { )) as UserPrivateKey; } - providerKeys$(userId: UserId) { - return this.userPrivateKey$(userId).pipe( - switchMap((userPrivateKey) => { - if (userPrivateKey == null) { - return of(null); - } - - return this.providerKeysHelper$(userId, userPrivateKey); - }), - ); - } - /** * A helper for decrypting provider keys that requires a user id and that users decrypted private key * this is helpful for when you may have already grabbed the user private key and don't want to redo diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.ts b/libs/pricing/src/components/cart-summary/cart-summary.component.ts index 11c6cddcab1..5f1da4a1cd8 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.ts +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.ts @@ -1,5 +1,6 @@ import { CurrencyPipe } from "@angular/common"; import { Component, computed, input, signal } from "@angular/core"; +import { toObservable } from "@angular/core/rxjs-interop"; import { TypographyModule, IconButtonModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; @@ -71,6 +72,11 @@ export class CartSummaryComponent { */ readonly total = computed<number>(() => this.getTotalCost()); + /** + * Observable of computed total value + */ + readonly total$ = toObservable(this.total); + /** * Toggles the expanded/collapsed state of the cart items */ diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.html b/libs/pricing/src/components/pricing-card/pricing-card.component.html index 8eae7088ac9..bc0ca68c5c3 100644 --- a/libs/pricing/src/components/pricing-card/pricing-card.component.html +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.html @@ -1,6 +1,4 @@ -<div - class="tw-box-border tw-bg-background tw-text-main tw-border tw-border-secondary-100 tw-rounded-3xl tw-p-8 tw-shadow-sm tw-size-full tw-flex tw-flex-col" -> +<bit-card class="tw-size-full tw-flex tw-flex-col"> <!-- Title Section with Active Badge --> <div class="tw-flex tw-items-center tw-justify-between tw-mb-2"> <ng-content select="[slot=title]"></ng-content> @@ -82,4 +80,4 @@ } } </div> -</div> +</bit-card> diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.ts b/libs/pricing/src/components/pricing-card/pricing-card.component.ts index a8fed031adf..f268c654331 100644 --- a/libs/pricing/src/components/pricing-card/pricing-card.component.ts +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.ts @@ -6,6 +6,7 @@ import { BadgeVariant, ButtonModule, ButtonType, + CardComponent, IconModule, TypographyModule, } from "@bitwarden/components"; @@ -20,7 +21,7 @@ import { @Component({ selector: "billing-pricing-card", templateUrl: "./pricing-card.component.html", - imports: [BadgeModule, ButtonModule, IconModule, TypographyModule, CurrencyPipe], + imports: [BadgeModule, ButtonModule, IconModule, TypographyModule, CurrencyPipe, CardComponent], }) export class PricingCardComponent { readonly tagline = input.required<string>(); diff --git a/libs/state/src/state-migrations/migrations/73-add-master-password-unlock-data.spec.ts b/libs/state/src/state-migrations/migrations/73-add-master-password-unlock-data.spec.ts index 28e65216653..2956b1cbcd2 100644 --- a/libs/state/src/state-migrations/migrations/73-add-master-password-unlock-data.spec.ts +++ b/libs/state/src/state-migrations/migrations/73-add-master-password-unlock-data.spec.ts @@ -97,6 +97,18 @@ describe("AddMasterPasswordUnlockData", () => { user_user1_kdfConfig_kdfConfig: { kdfType: 0, iterations: 600000 }, }); }); + + it("handles users with missing global accounts", async () => { + const output = await runMigrator(sut, { + global_account_accounts: { user_user1: null }, + user_user1_kdfConfig_kdfConfig: { kdfType: 0, iterations: 600000 }, + }); + + expect(output).toEqual({ + global_account_accounts: { user_user1: null }, + user_user1_kdfConfig_kdfConfig: { kdfType: 0, iterations: 600000 }, + }); + }); }); describe("rollback", () => { diff --git a/libs/state/src/state-migrations/migrations/73-add-master-password-unlock-data.ts b/libs/state/src/state-migrations/migrations/73-add-master-password-unlock-data.ts index b9833f439a6..321df7d5cfc 100644 --- a/libs/state/src/state-migrations/migrations/73-add-master-password-unlock-data.ts +++ b/libs/state/src/state-migrations/migrations/73-add-master-password-unlock-data.ts @@ -32,7 +32,7 @@ type Account = { export class AddMasterPasswordUnlockData extends Migrator<72, 73> { async migrate(helper: MigrationHelper): Promise<void> { async function migrateAccount(userId: string, account: Account) { - const email = account.email; + const email = account?.email; const kdfConfig = await helper.getFromUser(userId, KDF_CONFIG_DISK); const masterKeyEncryptedUserKey = await helper.getFromUser( userId, diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts index df317835392..4214873feed 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts @@ -343,7 +343,7 @@ describe("VaultExportService", () => { const exportData: BitwardenJsonExport = JSON.parse(data); expect(exportData.items.length).toBe(1); expect(exportData.items[0].id).toBe("mock-id"); - expect(exportData.items[0].organizationId).toBe(null); + expect(exportData.items[0].organizationId).toBeUndefined(); }); it.each([[400], [401], [404], [500]])( diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.abstraction.ts b/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.abstraction.ts index e25fec6eb82..0d58f168671 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.abstraction.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.abstraction.ts @@ -1,3 +1,5 @@ +import { Observable } from "rxjs"; + import { UserId, OrganizationId } from "@bitwarden/common/types/guid"; import { ExportedVault } from "../types"; @@ -5,6 +7,24 @@ import { ExportedVault } from "../types"; export const EXPORT_FORMATS = ["csv", "json", "encrypted_json", "zip"] as const; export type ExportFormat = (typeof EXPORT_FORMATS)[number]; +/** + * Options that determine which export formats are available + */ +export type FormatOptions = { + /** Whether the export is for the user's personal vault */ + isMyVault: boolean; +}; + +/** + * Metadata describing an available export format + */ +export type ExportFormatMetadata = { + /** Display name for the format (e.g., ".json", ".csv") */ + name: string; + /** The export format identifier */ + format: ExportFormat; +}; + export abstract class VaultExportServiceAbstraction { abstract getExport: ( userId: UserId, @@ -18,4 +38,11 @@ export abstract class VaultExportServiceAbstraction { password: string, onlyManagedCollections?: boolean, ) => Promise<ExportedVault>; + + /** + * Get available export formats based on vault context + * @param options Options determining which formats are available + * @returns Observable stream of available export formats + */ + abstract formats$(options: FormatOptions): Observable<ExportFormatMetadata[]>; } diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.ts index b601478d06d..38d71136006 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.ts @@ -1,4 +1,4 @@ -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, Observable, of } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; @@ -9,7 +9,12 @@ import { ExportedVault } from "../types"; import { IndividualVaultExportServiceAbstraction } from "./individual-vault-export.service.abstraction"; import { OrganizationVaultExportServiceAbstraction } from "./org-vault-export.service.abstraction"; -import { ExportFormat, VaultExportServiceAbstraction } from "./vault-export.service.abstraction"; +import { + ExportFormat, + ExportFormatMetadata, + FormatOptions, + VaultExportServiceAbstraction, +} from "./vault-export.service.abstraction"; export class VaultExportService implements VaultExportServiceAbstraction { constructor( @@ -85,6 +90,26 @@ export class VaultExportService implements VaultExportServiceAbstraction { ); } + /** + * Get available export formats based on vault context + * @param options Options determining which formats are available + * @returns Observable stream of available export formats + */ + formats$(options: FormatOptions): Observable<ExportFormatMetadata[]> { + const baseFormats: ExportFormatMetadata[] = [ + { name: ".json", format: "json" }, + { name: ".csv", format: "csv" }, + { name: ".json (Encrypted)", format: "encrypted_json" }, + ]; + + // ZIP format with attachments is only available for individual vault exports + if (options.isMyVault) { + return of([...baseFormats, { name: ".zip (with attachments)", format: "zip" }]); + } + + return of(baseFormats); + } + /** Checks if the provided userId matches the currently authenticated user * @param userId The userId to check * @throws Error if the userId does not match the currently authenticated user diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html index c638e5d7dde..f41375edd5a 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html @@ -35,7 +35,7 @@ <bit-form-field> <bit-label>{{ "fileFormat" | i18n }}</bit-label> <bit-select formControlName="format"> - <bit-option *ngFor="let f of formatOptions" [value]="f.value" [label]="f.name" /> + <bit-option *ngFor="let f of formatOptions$ | async" [value]="f.format" [label]="f.name" /> </bit-select> </bit-form-field> diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts index 19921b35162..610f30c1f67 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts @@ -67,7 +67,11 @@ import { } from "@bitwarden/components"; import { GeneratorServicesModule } from "@bitwarden/generator-components"; import { CredentialGeneratorService, GenerateRequest, Type } from "@bitwarden/generator-core"; -import { ExportedVault, VaultExportServiceAbstraction } from "@bitwarden/vault-export-core"; +import { + ExportedVault, + ExportFormatMetadata, + VaultExportServiceAbstraction, +} from "@bitwarden/vault-export-core"; import { EncryptedExportType } from "../enums/encrypted-export-type.enum"; @@ -231,11 +235,11 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { fileEncryptionType: [EncryptedExportType.AccountEncrypted], }); - formatOptions = [ - { name: ".json", value: "json" }, - { name: ".csv", value: "csv" }, - { name: ".json (Encrypted)", value: "encrypted_json" }, - ]; + /** + * Observable stream of available export format options + * Dynamically updates based on vault selection (My Vault vs Organization) + */ + formatOptions$: Observable<ExportFormatMetadata[]>; private destroy$ = new Subject<void>(); private onlyManagedCollections = true; @@ -338,17 +342,28 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { } private observeFormSelections(): void { - this.exportForm.controls.vaultSelector.valueChanges - .pipe(takeUntil(this.destroy$)) - .subscribe((value) => { - this.organizationId = value !== "myVault" ? value : undefined; + // Set up dynamic format options based on vault selection + this.formatOptions$ = this.exportForm.controls.vaultSelector.valueChanges.pipe( + startWith(this.exportForm.controls.vaultSelector.value), + map((vaultSelection) => { + const isMyVault = vaultSelection === "myVault"; + // Update organizationId based on vault selection + this.organizationId = isMyVault ? undefined : vaultSelection; + return { isMyVault }; + }), + switchMap((options) => this.exportService.formats$(options)), + tap((formats) => { + // Preserve the current format selection if it's still available in the new format list + const currentFormat = this.exportForm.get("format").value; + const isFormatAvailable = formats.some((f) => f.format === currentFormat); - this.formatOptions = this.formatOptions.filter((option) => option.value !== "zip"); - this.exportForm.get("format").setValue("json"); - if (value === "myVault") { - this.formatOptions.push({ name: ".zip (with attachments)", value: "zip" }); + // Only reset to json if the current format is no longer available + if (!isFormatAvailable) { + this.exportForm.get("format").setValue("json"); } - }); + }), + shareReplay({ bufferSize: 1, refCount: true }), + ); } /** diff --git a/libs/vault/src/cipher-form/components/additional-options/additional-options-section.component.spec.ts b/libs/vault/src/cipher-form/components/additional-options/additional-options-section.component.spec.ts index a9a327b90c0..6a574053367 100644 --- a/libs/vault/src/cipher-form/components/additional-options/additional-options-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/additional-options/additional-options-section.component.spec.ts @@ -13,11 +13,15 @@ import { CustomFieldsComponent } from "../custom-fields/custom-fields.component" import { AdditionalOptionsSectionComponent } from "./additional-options-section.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: "vault-custom-fields", template: "", }) class MockCustomFieldsComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() disableSectionMargin: boolean; } diff --git a/libs/vault/src/cipher-form/components/additional-options/additional-options-section.component.ts b/libs/vault/src/cipher-form/components/additional-options/additional-options-section.component.ts index 3a7152bfe24..f37d4f71f63 100644 --- a/libs/vault/src/cipher-form/components/additional-options/additional-options-section.component.ts +++ b/libs/vault/src/cipher-form/components/additional-options/additional-options-section.component.ts @@ -21,6 +21,8 @@ import { PasswordRepromptService } from "../../../services/password-reprompt.ser import { CipherFormContainer } from "../../cipher-form-container"; import { CustomFieldsComponent } from "../custom-fields/custom-fields.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: "vault-additional-options-section", templateUrl: "./additional-options-section.component.html", @@ -39,6 +41,8 @@ import { CustomFieldsComponent } from "../custom-fields/custom-fields.component" ], }) export class AdditionalOptionsSectionComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(CustomFieldsComponent) customFieldsComponent: CustomFieldsComponent; additionalOptionsForm = this.formBuilder.group({ @@ -56,6 +60,8 @@ export class AdditionalOptionsSectionComponent implements OnInit { /** True when the form is in `partial-edit` mode */ isPartialEdit = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() disableSectionMargin: boolean; /** True when the form allows new fields to be added */ diff --git a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts index c88ce9f0301..06f62976548 100644 --- a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts +++ b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts @@ -26,13 +26,21 @@ import { FakeAccountService, mockAccountServiceWith } from "../../../../../commo import { CipherAttachmentsComponent } from "./cipher-attachments.component"; import { DeleteAttachmentComponent } from "./delete-attachment/delete-attachment.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-download-attachment", template: "", }) class MockDownloadAttachmentComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() attachment: AttachmentView; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() cipher: CipherView; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() admin: boolean = false; } diff --git a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts index 9ae1c62bd3e..56c3414a12e 100644 --- a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts +++ b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts @@ -56,6 +56,8 @@ type CipherAttachmentForm = FormGroup<{ file: FormControl<File | null>; }>; +// 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-cipher-attachments", templateUrl: "./cipher-attachments.component.html", @@ -77,27 +79,43 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit { static attachmentFormID = "attachmentForm"; /** Reference to the file HTMLInputElement */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("fileInput", { read: ElementRef }) private fileInput: ElementRef<HTMLInputElement>; /** Reference to the BitSubmitDirective */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(BitSubmitDirective) bitSubmit: BitSubmitDirective; /** The `id` of the cipher in context */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) cipherId: CipherId; /** The organization ID if this cipher belongs to an organization */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() organizationId?: OrganizationId; /** Denotes if the action is occurring from within the admin console */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() admin: boolean = false; /** An optional submit button, whose loading/disabled state will be tied to the form state. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() submitBtn?: ButtonComponent; /** Emits after a file has been successfully uploaded */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onUploadSuccess = new EventEmitter<void>(); /** Emits after a file has been successfully removed */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onRemoveSuccess = new EventEmitter<void>(); organization: Organization; diff --git a/libs/vault/src/cipher-form/components/attachments/delete-attachment/delete-attachment.component.spec.ts b/libs/vault/src/cipher-form/components/attachments/delete-attachment/delete-attachment.component.spec.ts index 941b3740952..4e3899407d2 100644 --- a/libs/vault/src/cipher-form/components/attachments/delete-attachment/delete-attachment.component.spec.ts +++ b/libs/vault/src/cipher-form/components/attachments/delete-attachment/delete-attachment.component.spec.ts @@ -68,7 +68,7 @@ describe("DeleteAttachmentComponent", () => { it("renders delete button", () => { const deleteButton = fixture.debugElement.query(By.css("button")); - expect(deleteButton.attributes["title"]).toBe("deleteAttachmentName"); + expect(deleteButton.attributes["aria-label"]).toBe("deleteAttachmentName"); }); it("does not delete when the user cancels the dialog", async () => { diff --git a/libs/vault/src/cipher-form/components/attachments/delete-attachment/delete-attachment.component.ts b/libs/vault/src/cipher-form/components/attachments/delete-attachment/delete-attachment.component.ts index 60002ca5924..1bb3e071a0c 100644 --- a/libs/vault/src/cipher-form/components/attachments/delete-attachment/delete-attachment.component.ts +++ b/libs/vault/src/cipher-form/components/attachments/delete-attachment/delete-attachment.component.ts @@ -17,6 +17,8 @@ import { ToastService, } from "@bitwarden/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-delete-attachment", templateUrl: "./delete-attachment.component.html", @@ -24,15 +26,23 @@ import { }) export class DeleteAttachmentComponent { /** Id of the cipher associated with the attachment */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) cipherId!: string; /** The attachment that is can be deleted */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) attachment!: AttachmentView; /** Whether the attachment is being accessed from the admin console */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() admin: boolean = false; /** Emits when the attachment is successfully deleted */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onDeletionSuccess = new EventEmitter<void>(); constructor( diff --git a/libs/vault/src/cipher-form/components/autofill-options/advanced-uri-option-dialog.component.ts b/libs/vault/src/cipher-form/components/autofill-options/advanced-uri-option-dialog.component.ts index e63aa224149..f78c2c170f8 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/advanced-uri-option-dialog.component.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/advanced-uri-option-dialog.component.ts @@ -17,6 +17,8 @@ export type AdvancedUriOptionDialogParams = { onContinue: () => void; }; +// 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: "advanced-uri-option-dialog.component.html", imports: [ButtonLinkDirective, ButtonModule, DialogModule, JslibModule], diff --git a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts index 6a2b3e431ca..e6b8b5c9aca 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts @@ -36,6 +36,8 @@ interface UriField { matchDetection: UriMatchStrategySetting; } +// 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: "vault-autofill-options", templateUrl: "./autofill-options.component.html", @@ -60,6 +62,8 @@ export class AutofillOptionsComponent implements OnInit { /** * List of rendered UriOptionComponents. Used for focusing newly added Uri inputs. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChildren(UriOptionComponent) protected uriOptions: QueryList<UriOptionComponent>; diff --git a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts index 0d7f3663967..2d06f5dcc29 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts @@ -149,13 +149,17 @@ describe("UriOptionComponent", () => { expect(getMatchDetectionSelect()).not.toBeNull(); }); - it("should update the match detection button title when the toggle is clicked", () => { + it("should update the match detection button aria-label when the toggle is clicked", () => { component.writeValue({ uri: "https://example.com", matchDetection: UriMatchStrategy.Exact }); fixture.detectChanges(); - expect(getToggleMatchDetectionBtn().title).toBe("showMatchDetection https://example.com"); + expect(getToggleMatchDetectionBtn().getAttribute("aria-label")).toBe( + "showMatchDetection https://example.com", + ); getToggleMatchDetectionBtn().click(); fixture.detectChanges(); - expect(getToggleMatchDetectionBtn().title).toBe("hideMatchDetection https://example.com"); + expect(getToggleMatchDetectionBtn().getAttribute("aria-label")).toBe( + "hideMatchDetection https://example.com", + ); }); }); diff --git a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts index 8b6b6a6490b..b61109a45bb 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts @@ -36,6 +36,8 @@ import { import { AdvancedUriOptionDialogComponent } from "./advanced-uri-option-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: "vault-autofill-uri-option", templateUrl: "./uri-option.component.html", @@ -58,9 +60,13 @@ import { AdvancedUriOptionDialogComponent } from "./advanced-uri-option-dialog.c ], }) export class UriOptionComponent implements ControlValueAccessor { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("uriInput") private inputElement: ElementRef<HTMLInputElement>; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("matchDetectionSelect") private matchDetectionSelect: SelectComponent<UriMatchStrategySetting>; @@ -92,18 +98,24 @@ export class UriOptionComponent implements ControlValueAccessor { /** * Whether the option can be reordered. If false, the reorder button will be hidden. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) canReorder: boolean; /** * Whether the URI can be removed from the form. If false, the remove button will be hidden. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) canRemove: boolean; /** * The user's current default match detection strategy. Will be displayed in () after "Default" */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) set defaultMatchDetection(value: UriMatchStrategySetting) { // The default selection has a value of `null` avoid showing "Default (Default)" @@ -120,14 +132,20 @@ export class UriOptionComponent implements ControlValueAccessor { /** * The index of the URI in the form. Used to render the correct label. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) index: number; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onKeydown = new EventEmitter<KeyboardEvent>(); /** * Emits when the remove button is clicked and URI should be removed from the form. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() remove = new EventEmitter<void>(); diff --git a/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.ts b/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.ts index 7b8149b6d7b..5fa8d0af131 100644 --- a/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.ts @@ -23,6 +23,8 @@ import { import { CipherFormContainer } from "../../cipher-form-container"; +// 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: "vault-card-details-section", templateUrl: "./card-details-section.component.html", @@ -40,9 +42,13 @@ import { CipherFormContainer } from "../../cipher-form-container"; }) export class CardDetailsSectionComponent implements OnInit { /** The original cipher */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() originalCipherView: CipherView; /** True when all fields should be disabled */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() disabled: boolean; /** diff --git a/libs/vault/src/cipher-form/components/cipher-form.component.ts b/libs/vault/src/cipher-form/components/cipher-form.component.ts index f7676818edf..5e75ea5bc24 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.ts +++ b/libs/vault/src/cipher-form/components/cipher-form.component.ts @@ -49,6 +49,8 @@ import { LoginDetailsSectionComponent } from "./login-details-section/login-deta import { NewItemNudgeComponent } from "./new-item-nudge/new-item-nudge.component"; import { SshKeySectionComponent } from "./sshkey-section/sshkey-section.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: "vault-cipher-form", templateUrl: "./cipher-form.component.html", @@ -79,6 +81,8 @@ import { SshKeySectionComponent } from "./sshkey-section/sshkey-section.componen ], }) export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, CipherFormContainer { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(BitSubmitDirective) private bitSubmit: BitSubmitDirective; private destroyRef = inject(DestroyRef); @@ -87,38 +91,52 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci /** * The form ID to use for the form. Used to connect it to a submit button. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) formId: string; /** * The configuration for the add/edit form. Used to determine which controls are shown and what values are available. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) config: CipherFormConfig; /** * Optional submit button that will be disabled or marked as loading when the form is submitting. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() submitBtn?: ButtonComponent; /** * Optional function to call before submitting the form. If the function returns false, the form will not be submitted. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() beforeSubmit: () => Promise<boolean>; /** * Event emitted when the cipher is saved successfully. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() cipherSaved = new EventEmitter<CipherView>(); private formReadySubject = new Subject<void>(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() formReady = this.formReadySubject.asObservable(); /** * Emitted when the form is enabled */ private formStatusChangeSubject = new BehaviorSubject<"enabled" | "disabled" | null>(null); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() formStatusChange$ = this.formStatusChangeSubject.asObservable(); /** diff --git a/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.spec.ts b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.spec.ts index e98e4805d19..bc2b86f01ff 100644 --- a/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.spec.ts +++ b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.spec.ts @@ -6,19 +6,27 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { GeneratorModule } from "@bitwarden/generator-components"; import { CipherFormGeneratorComponent } from "@bitwarden/vault"; +// 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: "tools-password-generator", template: `<ng-content></ng-content>`, }) class MockPasswordGeneratorComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onGenerated = new EventEmitter(); } +// 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: "tools-username-generator", template: `<ng-content></ng-content>`, }) class MockUsernameGeneratorComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onGenerated = new EventEmitter(); } diff --git a/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.ts b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.ts index f1e4c5c177c..e053dd96973 100644 --- a/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.ts +++ b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.ts @@ -9,30 +9,42 @@ import { AlgorithmInfo, GeneratedCredential } from "@bitwarden/generator-core"; * Renders a password or username generator UI and emits the most recently generated value. * Used by the cipher form to be shown in a dialog/modal when generating cipher passwords/usernames. */ +// 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: "vault-cipher-form-generator", templateUrl: "./cipher-form-generator.component.html", imports: [CommonModule, GeneratorModule], }) export class CipherFormGeneratorComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() uri: string = ""; /** * The type of generator form to show. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) type: "password" | "username" = "password"; /** Removes bottom margin of internal sections */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ transform: coerceBooleanProperty }) disableMargin = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() algorithmSelected = new EventEmitter<AlgorithmInfo>(); /** * Emits an event when a new value is generated. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() valueGenerated = new EventEmitter<string>(); diff --git a/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.ts b/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.ts index 7d56db4366b..81720f8e612 100644 --- a/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.ts +++ b/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.ts @@ -28,6 +28,8 @@ export type AddEditCustomFieldDialogData = { disallowHiddenField?: 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: "vault-add-edit-custom-field-dialog", templateUrl: "./add-edit-custom-field-dialog.component.html", diff --git a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.ts b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.ts index 013ccd6c87e..b07d17af7d0 100644 --- a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.ts +++ b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.ts @@ -68,6 +68,8 @@ export type CustomField = { newField: 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: "vault-custom-fields", templateUrl: "./custom-fields.component.html", @@ -88,10 +90,16 @@ export type CustomField = { ], }) export class CustomFieldsComponent implements OnInit, AfterViewInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() numberOfFieldsChange = new EventEmitter<number>(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChildren("customFieldRow") customFieldRows: QueryList<ElementRef<HTMLDivElement>>; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() disableSectionMargin: boolean; customFieldsForm = this.formBuilder.group({ diff --git a/libs/vault/src/cipher-form/components/identity/identity.component.html b/libs/vault/src/cipher-form/components/identity/identity.component.html index 7f49bc21a10..2489977f63f 100644 --- a/libs/vault/src/cipher-form/components/identity/identity.component.html +++ b/libs/vault/src/cipher-form/components/identity/identity.component.html @@ -144,7 +144,7 @@ </bit-form-field> <bit-form-field> <bit-label> - {{ "zipPostalCode" | i18n }} + {{ "zipPostalCodeLabel" | i18n }} </bit-label> <input bitInput formControlName="postalCode" /> </bit-form-field> diff --git a/libs/vault/src/cipher-form/components/identity/identity.component.ts b/libs/vault/src/cipher-form/components/identity/identity.component.ts index 4c90024e05a..642a0cc4aff 100644 --- a/libs/vault/src/cipher-form/components/identity/identity.component.ts +++ b/libs/vault/src/cipher-form/components/identity/identity.component.ts @@ -21,6 +21,8 @@ import { import { CipherFormContainer } from "../../cipher-form-container"; +// 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: "vault-identity-section", templateUrl: "./identity.component.html", @@ -38,7 +40,11 @@ import { CipherFormContainer } from "../../cipher-form-container"; ], }) export class IdentitySectionComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() originalCipherView: CipherView; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() disabled: boolean; identityTitleOptions = [ { name: "-- " + this.i18nService.t("select") + " --", value: null }, diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts index 892fc5804ec..6fd74d86525 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts @@ -37,6 +37,8 @@ import { } from "../../abstractions/cipher-form-config.service"; import { CipherFormContainer } from "../../cipher-form-container"; +// 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: "vault-item-details-section", templateUrl: "./item-details-section.component.html", @@ -84,9 +86,13 @@ export class ItemDetailsSectionComponent implements OnInit { protected favoriteButtonDisabled = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) config: CipherFormConfig; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() originalCipherView: CipherView; diff --git a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.spec.ts index d6fe8a64921..8e60b9f32e0 100644 --- a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.spec.ts @@ -23,6 +23,8 @@ import { AutofillOptionsComponent } from "../autofill-options/autofill-options.c import { LoginDetailsSectionComponent } from "./login-details-section.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: "vault-autofill-options", template: "", diff --git a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.ts b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.ts index 061a8c4abf4..8b9c4ddeea1 100644 --- a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.ts @@ -30,6 +30,8 @@ import { TotpCaptureService } from "../../abstractions/totp-capture.service"; import { CipherFormContainer } from "../../cipher-form-container"; import { AutofillOptionsComponent } from "../autofill-options/autofill-options.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: "vault-login-details-section", templateUrl: "./login-details-section.component.html", diff --git a/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.ts b/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.ts index 70b94505731..5f4a44e5ef5 100644 --- a/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.ts +++ b/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.ts @@ -11,13 +11,15 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { UserId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/sdk-internal"; +// 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: "vault-new-item-nudge", templateUrl: "./new-item-nudge.component.html", imports: [SpotlightComponent, AsyncPipe], }) export class NewItemNudgeComponent { - configType = input.required<CipherType | null>(); + readonly configType = input.required<CipherType | null>(); activeUserId$ = this.accountService.activeAccount$.pipe(getUserId); showNewItemSpotlight$ = combineLatest([ this.activeUserId$, diff --git a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts index f92c4420d03..649dd807f29 100644 --- a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts +++ b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts @@ -25,6 +25,8 @@ import { generate_ssh_key } from "@bitwarden/sdk-internal"; import { SshImportPromptService } from "../../../services/ssh-import-prompt.service"; import { CipherFormContainer } from "../../cipher-form-container"; +// 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: "vault-sshkey-section", templateUrl: "./sshkey-section.component.html", @@ -42,9 +44,13 @@ import { CipherFormContainer } from "../../cipher-form-container"; }) export class SshKeySectionComponent implements OnInit { /** The original cipher */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() originalCipherView: CipherView; /** True when all fields should be disabled */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() disabled: boolean; /** diff --git a/libs/vault/src/cipher-view/additional-options/additional-options.component.ts b/libs/vault/src/cipher-view/additional-options/additional-options.component.ts index 3e632983d49..4933c137e51 100644 --- a/libs/vault/src/cipher-view/additional-options/additional-options.component.ts +++ b/libs/vault/src/cipher-view/additional-options/additional-options.component.ts @@ -11,6 +11,8 @@ import { FormFieldModule, } from "@bitwarden/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-additional-options", templateUrl: "additional-options.component.html", @@ -26,5 +28,7 @@ import { ], }) export class AdditionalOptionsComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() notes: string = ""; } diff --git a/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.ts b/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.ts index 711c63878e3..4e324d8002e 100644 --- a/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.ts +++ b/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.ts @@ -22,6 +22,8 @@ import { KeyService } from "@bitwarden/key-management"; import { DownloadAttachmentComponent } from "../../components/download-attachment/download-attachment.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-attachments-v2-view", templateUrl: "attachments-v2-view.component.html", @@ -36,11 +38,17 @@ import { DownloadAttachmentComponent } from "../../components/download-attachmen ], }) export class AttachmentsV2ViewComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() cipher: CipherView; // Required for fetching attachment data when viewed from cipher via emergency access + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() emergencyAccessId?: EmergencyAccessId; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() admin: boolean = false; canAccessPremium: boolean; diff --git a/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts b/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts index 11c15f63505..2796cae08d0 100644 --- a/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts +++ b/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts @@ -40,6 +40,8 @@ export interface AttachmentDialogCloseResult { /** * Component for the attachments dialog. */ +// 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-vault-attachments-v2", templateUrl: "attachments-v2.component.html", diff --git a/libs/vault/src/cipher-view/autofill-options/autofill-options-view.component.ts b/libs/vault/src/cipher-view/autofill-options/autofill-options-view.component.ts index 0643737d846..8bc55fb3760 100644 --- a/libs/vault/src/cipher-view/autofill-options/autofill-options-view.component.ts +++ b/libs/vault/src/cipher-view/autofill-options/autofill-options-view.component.ts @@ -18,6 +18,8 @@ import { TypographyModule, } from "@bitwarden/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-autofill-options-view", templateUrl: "autofill-options-view.component.html", @@ -32,7 +34,11 @@ import { ], }) export class AutofillOptionsViewComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() loginUris: LoginUriView[]; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() cipherId: string; constructor( diff --git a/libs/vault/src/cipher-view/card-details/card-details-view.component.ts b/libs/vault/src/cipher-view/card-details/card-details-view.component.ts index 502214848f3..d80aafde46b 100644 --- a/libs/vault/src/cipher-view/card-details/card-details-view.component.ts +++ b/libs/vault/src/cipher-view/card-details/card-details-view.component.ts @@ -17,6 +17,8 @@ import { import { ReadOnlyCipherCardComponent } from "../read-only-cipher-card/read-only-cipher-card.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-card-details-view", templateUrl: "card-details-view.component.html", @@ -31,6 +33,8 @@ import { ReadOnlyCipherCardComponent } from "../read-only-cipher-card/read-only- ], }) export class CardDetailsComponent implements OnChanges { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() cipher: CipherView; EventType = EventType; diff --git a/libs/vault/src/cipher-view/cipher-view.component.ts b/libs/vault/src/cipher-view/cipher-view.component.ts index 1a294be46aa..15cb7d4651f 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.ts +++ b/libs/vault/src/cipher-view/cipher-view.component.ts @@ -39,6 +39,8 @@ import { LoginCredentialsViewComponent } from "./login-credentials/login-credent import { SshKeyViewComponent } from "./sshkey-sections/sshkey-view.component"; import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-identity-sections.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-cipher-view", templateUrl: "cipher-view.component.html", @@ -61,9 +63,13 @@ import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-ide ], }) export class CipherViewComponent implements OnChanges, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) cipher: CipherView | null = null; // Required for fetching attachment data when viewed from cipher via emergency access + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() emergencyAccessId?: EmergencyAccessId; activeUserId$ = getUserId(this.accountService.activeAccount$); @@ -72,9 +78,13 @@ export class CipherViewComponent implements OnChanges, OnDestroy { * Optional list of collections the cipher is assigned to. If none are provided, they will be fetched using the * `CipherService` and the `collectionIds` property of the cipher. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() collections?: CollectionView[]; /** Should be set to true when the component is used within the Admin Console */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() isAdminConsole?: boolean = false; organization$: Observable<Organization | undefined> | undefined; diff --git a/libs/vault/src/cipher-view/custom-fields/custom-fields-v2.component.ts b/libs/vault/src/cipher-view/custom-fields/custom-fields-v2.component.ts index 7c2afd5029f..8b1eaab74bb 100644 --- a/libs/vault/src/cipher-view/custom-fields/custom-fields-v2.component.ts +++ b/libs/vault/src/cipher-view/custom-fields/custom-fields-v2.component.ts @@ -24,6 +24,8 @@ import { import { VaultAutosizeReadOnlyTextArea } from "../../directives/readonly-textarea.directive"; +// 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-custom-fields-v2", templateUrl: "custom-fields-v2.component.html", @@ -42,6 +44,8 @@ import { VaultAutosizeReadOnlyTextArea } from "../../directives/readonly-textare ], }) export class CustomFieldV2Component implements OnInit, OnChanges { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) cipher!: CipherView; fieldType = FieldType; fieldOptions: Map<number, LinkedMetadata> | undefined; diff --git a/libs/vault/src/cipher-view/item-details/item-details-v2.component.ts b/libs/vault/src/cipher-view/item-details/item-details-v2.component.ts index 31ba5c82d9d..2c310daad76 100644 --- a/libs/vault/src/cipher-view/item-details/item-details-v2.component.ts +++ b/libs/vault/src/cipher-view/item-details/item-details-v2.component.ts @@ -22,6 +22,8 @@ import { import { OrgIconDirective } from "../../components/org-icon.directive"; +// 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-item-details-v2", templateUrl: "item-details-v2.component.html", diff --git a/libs/vault/src/cipher-view/item-history/item-history-v2.component.ts b/libs/vault/src/cipher-view/item-history/item-history-v2.component.ts index 2bbb6418934..1295836d3d9 100644 --- a/libs/vault/src/cipher-view/item-history/item-history-v2.component.ts +++ b/libs/vault/src/cipher-view/item-history/item-history-v2.component.ts @@ -16,6 +16,8 @@ import { TypographyModule, } from "@bitwarden/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-item-history-v2", templateUrl: "item-history-v2.component.html", @@ -31,6 +33,8 @@ import { ], }) export class ItemHistoryV2Component { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() cipher: CipherView; constructor(private viewPasswordHistoryService: ViewPasswordHistoryService) {} diff --git a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.ts b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.ts index 5987d055e6b..4dbbf979b15 100644 --- a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.ts +++ b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.ts @@ -42,6 +42,8 @@ type TotpCodeValues = { totpCodeFormatted?: 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: "app-login-credentials-view", templateUrl: "login-credentials-view.component.html", @@ -61,10 +63,20 @@ type TotpCodeValues = { ], }) export class LoginCredentialsViewComponent implements OnChanges { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() cipher: CipherView; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() activeUserId: UserId; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() hadPendingChangePasswordTask: boolean; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() handleChangePassword = new EventEmitter<void>(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("passwordInput") private passwordInput!: ElementRef<HTMLInputElement>; diff --git a/libs/vault/src/cipher-view/read-only-cipher-card/read-only-cipher-card.component.ts b/libs/vault/src/cipher-view/read-only-cipher-card/read-only-cipher-card.component.ts index 8f6b9954a9f..7a17376472d 100644 --- a/libs/vault/src/cipher-view/read-only-cipher-card/read-only-cipher-card.component.ts +++ b/libs/vault/src/cipher-view/read-only-cipher-card/read-only-cipher-card.component.ts @@ -2,6 +2,8 @@ import { AfterViewInit, Component, ContentChildren, QueryList } from "@angular/c import { CardComponent, BitFormFieldComponent } from "@bitwarden/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: "read-only-cipher-card", templateUrl: "./read-only-cipher-card.component.html", @@ -11,6 +13,8 @@ import { CardComponent, BitFormFieldComponent } from "@bitwarden/components"; * A thin wrapper around the `bit-card` component that disables the bottom border for the last form field. */ export class ReadOnlyCipherCardComponent implements AfterViewInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ContentChildren(BitFormFieldComponent) formFields?: QueryList<BitFormFieldComponent>; ngAfterViewInit(): void { diff --git a/libs/vault/src/cipher-view/sshkey-sections/sshkey-view.component.ts b/libs/vault/src/cipher-view/sshkey-sections/sshkey-view.component.ts index 535c41b9aea..5d076d81cc7 100644 --- a/libs/vault/src/cipher-view/sshkey-sections/sshkey-view.component.ts +++ b/libs/vault/src/cipher-view/sshkey-sections/sshkey-view.component.ts @@ -14,6 +14,8 @@ import { import { ReadOnlyCipherCardComponent } from "../read-only-cipher-card/read-only-cipher-card.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-sshkey-view", templateUrl: "sshkey-view.component.html", @@ -28,6 +30,8 @@ import { ReadOnlyCipherCardComponent } from "../read-only-cipher-card/read-only- ], }) export class SshKeyViewComponent implements OnChanges { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() sshKey: SshKeyView; revealSshKey = false; diff --git a/libs/vault/src/cipher-view/view-identity-sections/view-identity-sections.component.ts b/libs/vault/src/cipher-view/view-identity-sections/view-identity-sections.component.ts index f9cb9d2b549..14fb7e2925c 100644 --- a/libs/vault/src/cipher-view/view-identity-sections/view-identity-sections.component.ts +++ b/libs/vault/src/cipher-view/view-identity-sections/view-identity-sections.component.ts @@ -12,6 +12,8 @@ import { import { ReadOnlyCipherCardComponent } from "../read-only-cipher-card/read-only-cipher-card.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-view-identity-sections", templateUrl: "./view-identity-sections.component.html", @@ -26,6 +28,8 @@ import { ReadOnlyCipherCardComponent } from "../read-only-cipher-card/read-only- ], }) export class ViewIdentitySectionsComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) cipher: CipherView | null = null; /** Returns all populated address fields */ diff --git a/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.ts b/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.ts index 0442bcd1f76..adc4c67b2f4 100644 --- a/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.ts +++ b/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.ts @@ -47,6 +47,8 @@ export type AddEditFolderDialogData = { editFolderConfig?: { folder: FolderView }; }; +// 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: "vault-add-edit-folder-dialog", templateUrl: "./add-edit-folder-dialog.component.html", @@ -62,7 +64,11 @@ export type AddEditFolderDialogData = { ], }) export class AddEditFolderDialogComponent implements AfterViewInit, OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(BitSubmitDirective) private bitSubmit?: BitSubmitDirective; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("submitBtn") private submitBtn?: ButtonComponent; folder: FolderView = new FolderView(); diff --git a/libs/vault/src/components/assign-collections.component.ts b/libs/vault/src/components/assign-collections.component.ts index 9890074a8c9..f0ce59b0c3c 100644 --- a/libs/vault/src/components/assign-collections.component.ts +++ b/libs/vault/src/components/assign-collections.component.ts @@ -96,6 +96,8 @@ export type CollectionAssignmentResult = UnionOfValues<typeof CollectionAssignme const MY_VAULT_ID = "MyVault"; +// 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: "assign-collections", templateUrl: "assign-collections.component.html", @@ -112,19 +114,29 @@ const MY_VAULT_ID = "MyVault"; ], }) export class AssignCollectionsComponent implements OnInit, OnDestroy, AfterViewInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(BitSubmitDirective) private bitSubmit: BitSubmitDirective; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() params: CollectionAssignmentParams; /** * Submit button instance that will be disabled or marked as loading when the form is submitting. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() submitBtn?: ButtonComponent; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() editableItemCountChange = new EventEmitter<number>(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onCollectionAssign = new EventEmitter<CollectionAssignmentResult>(); formGroup = this.formBuilder.group({ diff --git a/libs/vault/src/components/can-delete-cipher.directive.ts b/libs/vault/src/components/can-delete-cipher.directive.ts index 7eadedc7ada..8ab59f9d647 100644 --- a/libs/vault/src/components/can-delete-cipher.directive.ts +++ b/libs/vault/src/components/can-delete-cipher.directive.ts @@ -13,6 +13,8 @@ import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cip export class CanDeleteCipherDirective implements OnDestroy { private destroy$ = new Subject<void>(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input("appCanDeleteCipher") set cipher(cipher: CipherView) { this.viewContainer.clear(); diff --git a/libs/vault/src/components/carousel/carousel-button/carousel-button.component.ts b/libs/vault/src/components/carousel/carousel-button/carousel-button.component.ts index ae2ce12cba8..bef7f5b12d6 100644 --- a/libs/vault/src/components/carousel/carousel-button/carousel-button.component.ts +++ b/libs/vault/src/components/carousel/carousel-button/carousel-button.component.ts @@ -7,6 +7,8 @@ import { IconModule } from "@bitwarden/components"; import { VaultCarouselSlideComponent } from "../carousel-slide/carousel-slide.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: "vault-carousel-button", templateUrl: "carousel-button.component.html", @@ -14,15 +16,23 @@ import { VaultCarouselSlideComponent } from "../carousel-slide/carousel-slide.co }) export class VaultCarouselButtonComponent implements FocusableOption { /** Slide component that is associated with the individual button */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) slide!: VaultCarouselSlideComponent; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("btn", { static: true }) button!: ElementRef<HTMLButtonElement>; protected CarouselIcon = CarouselIcon; /** When set to true the button is shown in an active state. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) isActive!: boolean; /** Emits when the button is clicked. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onClick = new EventEmitter<void>(); /** Focuses the underlying button element. */ diff --git a/libs/vault/src/components/carousel/carousel-content/carousel-content.component.spec.ts b/libs/vault/src/components/carousel/carousel-content/carousel-content.component.spec.ts index bc1c9250c2c..5d396984f17 100644 --- a/libs/vault/src/components/carousel/carousel-content/carousel-content.component.spec.ts +++ b/libs/vault/src/components/carousel/carousel-content/carousel-content.component.spec.ts @@ -5,6 +5,8 @@ import { By } from "@angular/platform-browser"; import { VaultCarouselContentComponent } from "./carousel-content.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-test-template-ref", imports: [VaultCarouselContentComponent], @@ -17,6 +19,8 @@ import { VaultCarouselContentComponent } from "./carousel-content.component"; }) class TestTemplateRefComponent implements OnInit { // Test template content by creating a wrapping component and then pass a portal to the carousel content component. + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("template", { static: true }) template!: TemplateRef<any>; portal!: TemplatePortal; diff --git a/libs/vault/src/components/carousel/carousel-content/carousel-content.component.ts b/libs/vault/src/components/carousel/carousel-content/carousel-content.component.ts index 47027a77ae9..a3c3a9f1caf 100644 --- a/libs/vault/src/components/carousel/carousel-content/carousel-content.component.ts +++ b/libs/vault/src/components/carousel/carousel-content/carousel-content.component.ts @@ -1,6 +1,8 @@ import { TemplatePortal, CdkPortalOutlet } from "@angular/cdk/portal"; 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: "vault-carousel-content", templateUrl: "carousel-content.component.html", @@ -8,5 +10,7 @@ import { Component, Input } from "@angular/core"; }) export class VaultCarouselContentComponent { /** Content to be displayed for the carousel. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) content!: TemplatePortal; } diff --git a/libs/vault/src/components/carousel/carousel-slide/carousel-slide.component.spec.ts b/libs/vault/src/components/carousel/carousel-slide/carousel-slide.component.spec.ts index 46f06f6dcb4..116403362f4 100644 --- a/libs/vault/src/components/carousel/carousel-slide/carousel-slide.component.spec.ts +++ b/libs/vault/src/components/carousel/carousel-slide/carousel-slide.component.spec.ts @@ -5,6 +5,8 @@ import { By } from "@angular/platform-browser"; import { VaultCarouselSlideComponent } from "./carousel-slide.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-test-carousel-slide", imports: [VaultCarouselSlideComponent], diff --git a/libs/vault/src/components/carousel/carousel-slide/carousel-slide.component.ts b/libs/vault/src/components/carousel/carousel-slide/carousel-slide.component.ts index 811572881da..973a615d6f9 100644 --- a/libs/vault/src/components/carousel/carousel-slide/carousel-slide.component.ts +++ b/libs/vault/src/components/carousel/carousel-slide/carousel-slide.component.ts @@ -11,6 +11,8 @@ import { ViewContainerRef, } 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: "vault-carousel-slide", templateUrl: "./carousel-slide.component.html", @@ -18,7 +20,11 @@ import { }) export class VaultCarouselSlideComponent implements OnInit { /** `aria-label` that is assigned to the carousel toggle. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) label!: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ transform: booleanAttribute }) disablePadding = false; /** @@ -29,8 +35,12 @@ export class VaultCarouselSlideComponent implements OnInit { * * @remarks See note 4 of https://www.w3.org/WAI/ARIA/apg/patterns/tabpanel/ */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ transform: coerceBooleanProperty }) noFocusableChildren?: true; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(TemplateRef, { static: true }) implicitContent!: TemplateRef<unknown>; private _contentPortal: TemplatePortal | null = null; diff --git a/libs/vault/src/components/carousel/carousel.component.spec.ts b/libs/vault/src/components/carousel/carousel.component.spec.ts index ebb38576813..abbfe963ddf 100644 --- a/libs/vault/src/components/carousel/carousel.component.spec.ts +++ b/libs/vault/src/components/carousel/carousel.component.spec.ts @@ -7,6 +7,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.component"; import { VaultCarouselComponent } from "./carousel.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-test-carousel-slide", imports: [VaultCarouselComponent, VaultCarouselSlideComponent], diff --git a/libs/vault/src/components/carousel/carousel.component.ts b/libs/vault/src/components/carousel/carousel.component.ts index fdebbebc33b..4e180f09f9b 100644 --- a/libs/vault/src/components/carousel/carousel.component.ts +++ b/libs/vault/src/components/carousel/carousel.component.ts @@ -28,6 +28,8 @@ import { VaultCarouselButtonComponent } from "./carousel-button/carousel-button. import { VaultCarouselContentComponent } from "./carousel-content/carousel-content.component"; import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.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: "vault-carousel", templateUrl: "./carousel.component.html", @@ -50,30 +52,46 @@ export class VaultCarouselComponent implements AfterViewInit { * @remarks * The label should not include the word "carousel", `aria-roledescription="carousel"` is already included. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) label = ""; /** * Emits the index of the newly selected slide. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() slideChange = new EventEmitter<number>(); /** All slides within the carousel. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ContentChildren(VaultCarouselSlideComponent) slides!: QueryList<VaultCarouselSlideComponent>; /** All buttons that control the carousel */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChildren(VaultCarouselButtonComponent) carouselButtons!: QueryList<VaultCarouselButtonComponent>; /** Wrapping container for the carousel content and buttons */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("container") carouselContainer!: ElementRef<HTMLElement>; /** Container for the carousel buttons */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("carouselButtonWrapper") carouselButtonWrapper!: ElementRef<HTMLDivElement>; /** Temporary container containing `tempSlideOutlet` */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("tempSlideContainer") tempSlideContainer!: ElementRef<HTMLDivElement>; /** Outlet to temporary render each slide within */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(CdkPortalOutlet) tempSlideOutlet!: CdkPortalOutlet; /** The currently selected index of the carousel. */ diff --git a/libs/vault/src/components/copy-cipher-field.directive.ts b/libs/vault/src/components/copy-cipher-field.directive.ts index 9725adae5e2..7e8ca334f9e 100644 --- a/libs/vault/src/components/copy-cipher-field.directive.ts +++ b/libs/vault/src/components/copy-cipher-field.directive.ts @@ -30,13 +30,18 @@ import { CopyAction, CopyCipherFieldService } from "@bitwarden/vault"; selector: "[appCopyField]", }) export class CopyCipherFieldDirective implements OnChanges { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ alias: "appCopyField", required: true, }) action!: Exclude<CopyAction, "hiddenField">; - @Input({ required: true }) cipher!: CipherViewLike; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input({ required: true }) + cipher!: CipherViewLike; constructor( private copyCipherFieldService: CopyCipherFieldService, diff --git a/libs/vault/src/components/dark-image-source.directive.ts b/libs/vault/src/components/dark-image-source.directive.ts index ee54f61209a..b899ad472d4 100644 --- a/libs/vault/src/components/dark-image-source.directive.ts +++ b/libs/vault/src/components/dark-image-source.directive.ts @@ -40,7 +40,7 @@ export class DarkImageSourceDirective implements OnInit { /** * The image source to use when the dark theme is applied. */ - darkImgSrc = input.required<string>({ alias: "appDarkImgSrc" }); + readonly darkImgSrc = input.required<string>({ alias: "appDarkImgSrc" }); @HostBinding("attr.src") src: string | undefined; diff --git a/libs/vault/src/components/decryption-failure-dialog/decryption-failure-dialog.component.ts b/libs/vault/src/components/decryption-failure-dialog/decryption-failure-dialog.component.ts index 91b1cef364c..628de79b3da 100644 --- a/libs/vault/src/components/decryption-failure-dialog/decryption-failure-dialog.component.ts +++ b/libs/vault/src/components/decryption-failure-dialog/decryption-failure-dialog.component.ts @@ -19,6 +19,8 @@ export type DecryptionFailureDialogParams = { cipherIds: CipherId[]; }; +// 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: "vault-decryption-failure-dialog", templateUrl: "./decryption-failure-dialog.component.html", diff --git a/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts b/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts index 8ba7b29a526..ec5a9ce96fd 100644 --- a/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts +++ b/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts @@ -108,7 +108,7 @@ describe("DownloadAttachmentComponent", () => { it("renders delete button", () => { const deleteButton = fixture.debugElement.query(By.css("button")); - expect(deleteButton.attributes["title"]).toBe("downloadAttachmentName"); + expect(deleteButton.attributes["aria-label"]).toBe("downloadAttachmentName"); }); describe("download attachment", () => { diff --git a/libs/vault/src/components/download-attachment/download-attachment.component.ts b/libs/vault/src/components/download-attachment/download-attachment.component.ts index 8208887b888..2f9cd528990 100644 --- a/libs/vault/src/components/download-attachment/download-attachment.component.ts +++ b/libs/vault/src/components/download-attachment/download-attachment.component.ts @@ -17,6 +17,8 @@ import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.v import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { AsyncActionsModule, IconButtonModule, ToastService } from "@bitwarden/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-download-attachment", templateUrl: "./download-attachment.component.html", @@ -24,18 +26,28 @@ import { AsyncActionsModule, IconButtonModule, ToastService } from "@bitwarden/c }) export class DownloadAttachmentComponent { /** Attachment to download */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) attachment: AttachmentView; /** The cipher associated with the attachment */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) cipher: CipherView; // When in view mode, we will want to check for the master password reprompt + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() checkPwReprompt?: boolean = false; // Required for fetching attachment data when viewed from cipher via emergency access + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() emergencyAccessId?: EmergencyAccessId; /** When owners/admins can mange all items and when accessing from the admin console, use the admin endpoint */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() admin?: boolean = false; constructor( diff --git a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.ts b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.ts index 82bbc9a0749..0a755a9cdb4 100644 --- a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.ts +++ b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.ts @@ -9,15 +9,25 @@ import { CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-ite import { ButtonModule, MenuModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; +// 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: "vault-new-cipher-menu", templateUrl: "new-cipher-menu.component.html", imports: [ButtonModule, CommonModule, MenuModule, I18nPipe, JslibModule], }) export class NewCipherMenuComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals canCreateCipher = input(false); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals canCreateFolder = input(false); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals canCreateCollection = input(false); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals canCreateSshKey = input(false); folderAdded = output(); collectionAdded = output(); diff --git a/libs/vault/src/components/org-icon.directive.ts b/libs/vault/src/components/org-icon.directive.ts index d9c8f240474..e9f28cb246a 100644 --- a/libs/vault/src/components/org-icon.directive.ts +++ b/libs/vault/src/components/org-icon.directive.ts @@ -8,7 +8,11 @@ export type OrgIconSize = "default" | "small" | "large"; selector: "[appOrgIcon]", }) export class OrgIconDirective { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) tierType!: ProductTierType; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() size?: OrgIconSize = "default"; constructor( diff --git a/libs/vault/src/components/password-history-view/password-history-view.component.ts b/libs/vault/src/components/password-history-view/password-history-view.component.ts index 427644f3e77..e7d64cfdfdc 100644 --- a/libs/vault/src/components/password-history-view/password-history-view.component.ts +++ b/libs/vault/src/components/password-history-view/password-history-view.component.ts @@ -8,6 +8,8 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { PasswordHistoryView } from "@bitwarden/common/vault/models/view/password-history.view"; import { ItemModule, ColorPasswordModule, IconButtonModule } from "@bitwarden/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: "vault-password-history-view", templateUrl: "./password-history-view.component.html", @@ -17,6 +19,8 @@ export class PasswordHistoryViewComponent implements OnInit { /** * Optional cipher view. When included `cipherId` is ignored. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) cipher: CipherView; /** The password history for the cipher. */ diff --git a/libs/vault/src/components/password-history/password-history.component.ts b/libs/vault/src/components/password-history/password-history.component.ts index 7845edb2369..dd2865fa2ce 100644 --- a/libs/vault/src/components/password-history/password-history.component.ts +++ b/libs/vault/src/components/password-history/password-history.component.ts @@ -26,6 +26,8 @@ export interface ViewPasswordHistoryDialogParams { /** * A dialog component that displays the password history for a cipher. */ +// 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-vault-password-history", templateUrl: "password-history.component.html", diff --git a/libs/vault/src/components/password-reprompt.component.ts b/libs/vault/src/components/password-reprompt.component.ts index 7665b22be49..f5245f5cad6 100644 --- a/libs/vault/src/components/password-reprompt.component.ts +++ b/libs/vault/src/components/password-reprompt.component.ts @@ -22,6 +22,8 @@ import { KeyService } from "@bitwarden/key-management"; * Used to verify the user's Master Password for the "Master Password Re-prompt" feature only. * See UserVerificationComponent for any other situation where you need to verify the user's identity. */ +// 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: "vault-password-reprompt", imports: [ diff --git a/libs/vault/src/components/permit-cipher-details-popover/permit-cipher-details-popover.component.ts b/libs/vault/src/components/permit-cipher-details-popover/permit-cipher-details-popover.component.ts index 8e80ddf7810..3649c8a21e1 100644 --- a/libs/vault/src/components/permit-cipher-details-popover/permit-cipher-details-popover.component.ts +++ b/libs/vault/src/components/permit-cipher-details-popover/permit-cipher-details-popover.component.ts @@ -4,6 +4,8 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { LinkModule, PopoverModule } from "@bitwarden/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: "vault-permit-cipher-details-popover", templateUrl: "./permit-cipher-details-popover.component.html", diff --git a/libs/vault/src/components/totp-countdown/totp-countdown.component.ts b/libs/vault/src/components/totp-countdown/totp-countdown.component.ts index 5274ce621f0..32f9a64bb87 100644 --- a/libs/vault/src/components/totp-countdown/totp-countdown.component.ts +++ b/libs/vault/src/components/totp-countdown/totp-countdown.component.ts @@ -15,13 +15,19 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { TotpInfo } from "@bitwarden/common/vault/services/totp.service"; import { TypographyModule } from "@bitwarden/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: "[bitTotpCountdown]", templateUrl: "totp-countdown.component.html", imports: [CommonModule, TypographyModule], }) export class BitTotpCountdownComponent implements OnInit, OnChanges { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) cipher!: CipherView; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() sendCopyCode = new EventEmitter(); /** diff --git a/libs/vault/src/services/at-risk-password-callout.service.spec.ts b/libs/vault/src/services/at-risk-password-callout.service.spec.ts index 47b83f4a903..5b687970c4b 100644 --- a/libs/vault/src/services/at-risk-password-callout.service.spec.ts +++ b/libs/vault/src/services/at-risk-password-callout.service.spec.ts @@ -28,6 +28,8 @@ class MockCipherView { constructor( public id: string, private deleted: boolean, + public edit: boolean = true, + public viewPassword: boolean = true, ) {} get isDeleted() { return this.deleted; @@ -65,33 +67,261 @@ describe("AtRiskPasswordCalloutService", () => { service = TestBed.inject(AtRiskPasswordCalloutService); }); + describe("pendingTasks$", () => { + it.each([ + { + description: + "returns tasks filtered by UpdateAtRiskCredential type with valid cipher permissions", + tasks: [ + { + id: "t1", + cipherId: "c1", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + { + id: "t2", + cipherId: "c2", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + ], + ciphers: [ + new MockCipherView("c1", false, true, true), + new MockCipherView("c2", false, true, true), + ], + expectedLength: 2, + expectedFirstId: "t1", + }, + { + description: "filters out tasks with wrong task type", + tasks: [ + { + id: "t1", + cipherId: "c1", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + { + id: "t2", + cipherId: "c2", + type: 999 as SecurityTaskType, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + ], + ciphers: [ + new MockCipherView("c1", false, true, true), + new MockCipherView("c2", false, true, true), + ], + expectedLength: 1, + expectedFirstId: "t1", + }, + { + description: "filters out tasks with missing associated cipher", + tasks: [ + { + id: "t1", + cipherId: "c1", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + { + id: "t2", + cipherId: "c-nonexistent", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + ], + ciphers: [new MockCipherView("c1", false, true, true)], + expectedLength: 1, + expectedFirstId: "t1", + }, + { + description: "filters out tasks when cipher edit permission is false", + tasks: [ + { + id: "t1", + cipherId: "c1", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + { + id: "t2", + cipherId: "c2", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + ], + ciphers: [ + new MockCipherView("c1", false, true, true), + new MockCipherView("c2", false, false, true), + ], + expectedLength: 1, + expectedFirstId: "t1", + }, + { + description: "filters out tasks when cipher viewPassword permission is false", + tasks: [ + { + id: "t1", + cipherId: "c1", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + { + id: "t2", + cipherId: "c2", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + ], + ciphers: [ + new MockCipherView("c1", false, true, true), + new MockCipherView("c2", false, true, false), + ], + expectedLength: 1, + expectedFirstId: "t1", + }, + { + description: "filters out tasks when cipher is deleted", + tasks: [ + { + id: "t1", + cipherId: "c1", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + { + id: "t2", + cipherId: "c2", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + ], + ciphers: [ + new MockCipherView("c1", false, true, true), + new MockCipherView("c2", true, true, true), + ], + expectedLength: 1, + expectedFirstId: "t1", + }, + ])("$description", async ({ tasks, ciphers, expectedLength, expectedFirstId }) => { + jest.spyOn(mockTaskService, "pendingTasks$").mockReturnValue(of(tasks)); + jest.spyOn(mockCipherService, "cipherViews$").mockReturnValue(of(ciphers)); + + const result = await firstValueFrom(service.pendingTasks$(userId)); + + expect(result).toHaveLength(expectedLength); + if (expectedFirstId) { + expect(result[0].id).toBe(expectedFirstId); + } + }); + + it("correctly filters mixed valid and invalid tasks", async () => { + const tasks: SecurityTask[] = [ + { + id: "t1", + cipherId: "c1", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + { + id: "t2", + cipherId: "c2", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + { + id: "t3", + cipherId: "c3", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + { + id: "t4", + cipherId: "c4", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + { + id: "t5", + cipherId: "c5", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + ]; + const ciphers = [ + new MockCipherView("c1", false, true, true), // valid + new MockCipherView("c2", false, false, true), // no edit + new MockCipherView("c3", true, true, true), // deleted + new MockCipherView("c4", false, true, false), // no viewPassword + // c5 missing + ]; + + jest.spyOn(mockTaskService, "pendingTasks$").mockReturnValue(of(tasks)); + jest.spyOn(mockCipherService, "cipherViews$").mockReturnValue(of(ciphers)); + + const result = await firstValueFrom(service.pendingTasks$(userId)); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe("t1"); + }); + + it.each([ + { + description: "returns empty array when no tasks match filter criteria", + tasks: [ + { + id: "t1", + cipherId: "c1", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as SecurityTask, + ], + ciphers: [new MockCipherView("c1", true, true, true)], // deleted + }, + { + description: "returns empty array when no pending tasks exist", + tasks: [], + ciphers: [new MockCipherView("c1", false, true, true)], + }, + ])("$description", async ({ tasks, ciphers }) => { + jest.spyOn(mockTaskService, "pendingTasks$").mockReturnValue(of(tasks)); + jest.spyOn(mockCipherService, "cipherViews$").mockReturnValue(of(ciphers)); + + const result = await firstValueFrom(service.pendingTasks$(userId)); + + expect(result).toHaveLength(0); + }); + }); + describe("completedTasks$", () => { - it(" should return true if completed tasks exist", async () => { + it("returns true if completed tasks exist", async () => { const tasks: SecurityTask[] = [ { id: "t1", cipherId: "c1", type: SecurityTaskType.UpdateAtRiskCredential, status: SecurityTaskStatus.Completed, - } as any, + } as SecurityTask, { id: "t2", cipherId: "c2", type: SecurityTaskType.UpdateAtRiskCredential, status: SecurityTaskStatus.Pending, - } as any, + } as SecurityTask, { id: "t3", cipherId: "nope", type: SecurityTaskType.UpdateAtRiskCredential, status: SecurityTaskStatus.Completed, - } as any, + } as SecurityTask, { id: "t4", cipherId: "c3", type: SecurityTaskType.UpdateAtRiskCredential, status: SecurityTaskStatus.Completed, - } as any, + } as SecurityTask, ]; jest.spyOn(mockTaskService, "completedTasks$").mockReturnValue(of(tasks)); @@ -110,7 +340,7 @@ describe("AtRiskPasswordCalloutService", () => { jest.spyOn(mockCipherService, "cipherViews$").mockReturnValue(of([])); }); - it("should return false if banner has been dismissed", async () => { + it("returns false if banner has been dismissed", async () => { const state: AtRiskPasswordCalloutData = { hasInteractedWithTasks: true, tasksBannerDismissed: true, @@ -123,7 +353,7 @@ describe("AtRiskPasswordCalloutService", () => { expect(result).toBe(false); }); - it("should return true when has completed tasks, no pending tasks, and banner not dismissed", async () => { + it("returns true when has completed tasks, no pending tasks, and banner not dismissed", async () => { const completedTasks = [ { id: "t1", diff --git a/libs/vault/src/services/at-risk-password-callout.service.ts b/libs/vault/src/services/at-risk-password-callout.service.ts index d3af4f8421e..214a061399e 100644 --- a/libs/vault/src/services/at-risk-password-callout.service.ts +++ b/libs/vault/src/services/at-risk-password-callout.service.ts @@ -45,6 +45,8 @@ export class AtRiskPasswordCalloutService { return ( t.type === SecurityTaskType.UpdateAtRiskCredential && associatedCipher && + associatedCipher.edit && + associatedCipher.viewPassword && !associatedCipher.isDeleted ); }); diff --git a/package-lock.json b/package-lock.json index c9abe11b585..d1858d4d508 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,8 @@ "@angular/platform-browser": "19.2.14", "@angular/platform-browser-dynamic": "19.2.14", "@angular/router": "19.2.14", - "@bitwarden/sdk-internal": "0.2.0-main.315", + "@bitwarden/commercial-sdk-internal": "0.2.0-main.357", + "@bitwarden/sdk-internal": "0.2.0-main.357", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0", @@ -111,7 +112,7 @@ "@types/koa-json": "2.0.23", "@types/lowdb": "1.0.15", "@types/lunr": "2.3.7", - "@types/node": "22.15.3", + "@types/node": "22.18.11", "@types/node-fetch": "2.6.4", "@types/node-forge": "1.3.11", "@types/papaparse": "5.3.16", @@ -124,11 +125,11 @@ "@yao-pkg/pkg": "6.5.1", "angular-eslint": "19.6.0", "autoprefixer": "10.4.21", - "axe-playwright": "2.1.0", + "axe-playwright": "2.2.2", "babel-loader": "9.2.1", "base64-loader": "1.0.0", "browserslist": "4.23.2", - "chromatic": "13.1.2", + "chromatic": "13.3.1", "concurrently": "9.2.0", "copy-webpack-plugin": "13.0.0", "cross-env": "10.1.0", @@ -4605,6 +4606,27 @@ "resolved": "libs/client-type", "link": true }, + "node_modules/@bitwarden/commercial-sdk-internal": { + "version": "0.2.0-main.357", + "resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.357.tgz", + "integrity": "sha512-eIArJelJKwG+aEGbtdhc5dKRBFopmyGJl+ClUQGJUFHzfrPGDcaSI04a/sSUK0NtbaxQOsf8qSvk+iKvISkKmw==", + "license": "BITWARDEN SOFTWARE DEVELOPMENT KIT LICENSE AGREEMENT", + "dependencies": { + "type-fest": "^4.41.0" + } + }, + "node_modules/@bitwarden/commercial-sdk-internal/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@bitwarden/common": { "resolved": "libs/common", "link": true @@ -4690,9 +4712,9 @@ "link": true }, "node_modules/@bitwarden/sdk-internal": { - "version": "0.2.0-main.315", - "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.315.tgz", - "integrity": "sha512-hdpFRLrDYSJ6+cNXfMyHdTgg/xIePIlEUSn4JWzwru4PvTcEkkFwGJM3L2LoUqTdNMiDQlr0UjDahopT+C2r0g==", + "version": "0.2.0-main.357", + "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.357.tgz", + "integrity": "sha512-qo8kCzrWNJP69HeI6WRyJMCFXYUJqLbaQCFoDgQkQa3ICrwpw5g9gW5y4P9FOa/DHdj8BgVbFGAX+YylbUb0/A==", "license": "GPL-3.0", "dependencies": { "type-fest": "^4.41.0" @@ -14369,9 +14391,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.15.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.3.tgz", - "integrity": "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==", + "version": "22.18.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.11.tgz", + "integrity": "sha512-Gd33J2XIrXurb+eT2ktze3rJAfAp9ZNjlBdh4SVgyrKEOADwCbdUDaK7QgJno8Ue4kcajscsKqu6n8OBG3hhCQ==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -16944,9 +16966,9 @@ } }, "node_modules/axe-playwright": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/axe-playwright/-/axe-playwright-2.1.0.tgz", - "integrity": "sha512-tY48SX56XaAp16oHPyD4DXpybz8Jxdz9P7exTjF/4AV70EGUavk+1fUPWirM0OYBR+YyDx6hUeDvuHVA6fB9YA==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/axe-playwright/-/axe-playwright-2.2.2.tgz", + "integrity": "sha512-h350/grzDCPgpuWV7eEOqr/f61Xn07Gi9f9B3Ew4rW6/nFtpdEJYW6jgRATorgAGXjEAYFTnaY3sEys39wDw4A==", "dev": true, "license": "MIT", "dependencies": { @@ -18470,9 +18492,9 @@ } }, "node_modules/chromatic": { - "version": "13.1.2", - "resolved": "https://registry.npmjs.org/chromatic/-/chromatic-13.1.2.tgz", - "integrity": "sha512-jgVptQabJHOnzmmvLjbtfutREkWGhDDk2gVqMH6N+V7z56oIy4Sd2/U7ZxNvnVFPinZQMSjSdUce4b6JIP64Dg==", + "version": "13.3.1", + "resolved": "https://registry.npmjs.org/chromatic/-/chromatic-13.3.1.tgz", + "integrity": "sha512-qJ/el70Wo7jFgiXPpuukqxCEc7IKiH/e8MjTzIF9uKw+3XZ6GghOTTLC7lGfeZtosiQBMkRlYet77tC4KKHUng==", "dev": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index 88cf2bda43c..32056a174b1 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "test:types": "node ./scripts/test-types.js", "test:locales": "tsc --project ./scripts/tsconfig.json && node ./scripts/dist/test-locales.js", "lint:dep-ownership": "tsc --project ./scripts/tsconfig.json && node ./scripts/dist/dep-ownership.js", + "lint:sdk-internal-versions": "tsc --project ./scripts/tsconfig.json && node ./scripts/dist/sdk-internal-versions.js", "docs:json": "compodoc -p ./tsconfig.json -e json -d . --disableRoutesGraph", "storybook": "ng run components:storybook", "build-storybook": "ng run components:build-storybook", @@ -74,7 +75,7 @@ "@types/koa-json": "2.0.23", "@types/lowdb": "1.0.15", "@types/lunr": "2.3.7", - "@types/node": "22.15.3", + "@types/node": "22.18.11", "@types/node-fetch": "2.6.4", "@types/node-forge": "1.3.11", "@types/papaparse": "5.3.16", @@ -87,11 +88,11 @@ "@yao-pkg/pkg": "6.5.1", "angular-eslint": "19.6.0", "autoprefixer": "10.4.21", - "axe-playwright": "2.1.0", + "axe-playwright": "2.2.2", "babel-loader": "9.2.1", "base64-loader": "1.0.0", "browserslist": "4.23.2", - "chromatic": "13.1.2", + "chromatic": "13.3.1", "concurrently": "9.2.0", "copy-webpack-plugin": "13.0.0", "cross-env": "10.1.0", @@ -159,7 +160,8 @@ "@angular/platform-browser": "19.2.14", "@angular/platform-browser-dynamic": "19.2.14", "@angular/router": "19.2.14", - "@bitwarden/sdk-internal": "0.2.0-main.315", + "@bitwarden/sdk-internal": "0.2.0-main.357", + "@bitwarden/commercial-sdk-internal": "0.2.0-main.357", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0", diff --git a/scripts/sdk-internal-versions.ts b/scripts/sdk-internal-versions.ts new file mode 100644 index 00000000000..c442772e553 --- /dev/null +++ b/scripts/sdk-internal-versions.ts @@ -0,0 +1,22 @@ +/* eslint-disable no-console */ + +/// Ensure that `sdk-internal` and `commercial-sdk-internal` dependencies have matching versions. + +import fs from "fs"; +import path from "path"; + +const packageJson = JSON.parse( + fs.readFileSync(path.join(__dirname, "..", "..", "package.json"), "utf8"), +); + +const sdkInternal = packageJson.dependencies["@bitwarden/sdk-internal"]; +const commercialSdkInternal = packageJson.dependencies["@bitwarden/commercial-sdk-internal"]; + +if (sdkInternal !== commercialSdkInternal) { + console.error( + `Version mismatch between @bitwarden/sdk-internal (${sdkInternal}) and @bitwarden/commercial-sdk-internal (${commercialSdkInternal}), must be an exact match.`, + ); + process.exit(1); +} + +console.log(`All dependencies have matching versions: ${sdkInternal}`);