diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5ba5885d725..93693f183c3 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -122,6 +122,9 @@ apps/cli/src/locales/en/messages.json apps/desktop/src/locales/en/messages.json apps/web/src/locales/en/messages.json +## Ssh agent temporary co-codeowner +apps/desktop/desktop_native/core/src/ssh_agent @bitwarden/team-platform-dev @bitwarden/wg-ssh-keys + ## BRE team owns these workflows ## .github/workflows/brew-bump-desktop.yml @bitwarden/dept-bre .github/workflows/deploy-web.yml @bitwarden/dept-bre diff --git a/.github/renovate.json b/.github/renovate.json index c9cfd548956..0172403f0f1 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -41,16 +41,12 @@ }, { "matchPackageNames": [ - "@ngtools/webpack", "base64-loader", "buffer", "bufferutil", - "copy-webpack-plugin", "core-js", "css-loader", "html-loader", - "html-webpack-injector", - "html-webpack-plugin", "mini-css-extract-plugin", "ngx-infinite-scroll", "postcss", @@ -60,20 +56,15 @@ "sass-loader", "style-loader", "ts-loader", - "tsconfig-paths-webpack-plugin", "url", - "util", - "webpack", - "webpack-cli", - "webpack-dev-server", - "webpack-node-externals" + "util" ], "description": "Admin Console owned dependencies", "commitMessagePrefix": "[deps] AC:", "reviewers": ["team:team-admin-console-dev"] }, { - "matchPackageNames": ["@types/node-ipc", "node-ipc", "qrious"], + "matchPackageNames": ["qrious"], "description": "Auth owned dependencies", "commitMessagePrefix": "[deps] Auth:", "reviewers": ["team:team-auth-dev"] @@ -110,27 +101,43 @@ }, { "matchPackageNames": [ + "@babel/core", + "@babel/preset-env", "@electron/notarize", "@electron/rebuild", - "@types/argon2-browser", + "@ngtools/webpack", "@types/chrome", "@types/firefox-webext-browser", + "@types/glob", "@types/jquery", + "@types/lowdb", "@types/node", "@types/node-forge", - "argon2", - "argon2-browser", - "big-integer", + "@types/node-ipc", + "@yao-pkg", + "babel-loader", + "browserslist", + "copy-webpack-plugin", + "electron", "electron-builder", "electron-log", "electron-reload", "electron-store", "electron-updater", - "electron", + "html-webpack-injector", + "html-webpack-plugin", + "lowdb", "node-forge", + "node-ipc", + "pkg", "rxjs", + "tsconfig-paths-webpack-plugin", "type-fest", - "typescript" + "typescript", + "webpack", + "webpack-cli", + "webpack-dev-server", + "webpack-node-externals" ], "description": "Platform owned dependencies", "commitMessagePrefix": "[deps] Platform:", @@ -231,7 +238,6 @@ "@types/koa__router", "@types/koa-bodyparser", "@types/koa-json", - "@types/lowdb", "@types/lunr", "@types/node-fetch", "@types/proper-lockfile", @@ -244,18 +250,22 @@ "koa", "koa-bodyparser", "koa-json", - "lowdb", "lunr", "multer", "node-fetch", "open", - "pkg", "proper-lockfile", "qrcode-parser" ], "description": "Vault owned dependencies", "commitMessagePrefix": "[deps] Vault:", "reviewers": ["team:team-vault-dev"] + }, + { + "matchPackageNames": ["@types/argon2-browser", "argon2", "argon2-browser", "big-integer"], + "description": "Key Management owned dependencies", + "commitMessagePrefix": "[deps] KM:", + "reviewers": ["team:team-key-management-dev"] } ], "ignoreDeps": ["@types/koa-bodyparser", "bootstrap", "node-ipc", "node", "npm"] diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index 34c69912f50..42d012d5a98 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -1,7 +1,8 @@ name: Build Browser on: - pull_request: + pull_request_target: + types: [opened, synchronize] branches-ignore: - 'l10n_master' - 'cf-pages' @@ -33,16 +34,24 @@ defaults: shell: bash jobs: + check-run: + name: Check PR run + uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main + setup: name: Setup runs-on: ubuntu-22.04 + needs: + - check-run outputs: repo_url: ${{ steps.gen_vars.outputs.repo_url }} adj_build_number: ${{ steps.gen_vars.outputs.adj_build_number }} node_version: ${{ steps.retrieve-node-version.outputs.node_version }} steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Get Package Version id: gen_vars @@ -71,8 +80,10 @@ jobs: run: working-directory: apps/browser steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Testing locales - extName length run: | @@ -109,8 +120,10 @@ jobs: _BUILD_NUMBER: ${{ needs.setup.outputs.adj_build_number }} _NODE_VERSION: ${{ needs.setup.outputs.node_version }} steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 @@ -229,8 +242,10 @@ jobs: _BUILD_NUMBER: ${{ needs.setup.outputs.adj_build_number }} _NODE_VERSION: ${{ needs.setup.outputs.node_version }} steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 @@ -336,14 +351,16 @@ jobs: crowdin-push: name: Crowdin Push - if: github.ref == 'refs/heads/main' + if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' runs-on: ubuntu-22.04 needs: - build - build-safari steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Login to Azure uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 @@ -381,7 +398,10 @@ jobs: - crowdin-push steps: - name: Check if any job failed - if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && contains(needs.*.result, 'failure') + if: | + github.event_name != 'pull_request_target' + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc-browser') + && contains(needs.*.result, 'failure') run: exit 1 - name: Login to Azure - Prod Subscription diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index 7994e508b3c..ac39ab2608b 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -1,7 +1,8 @@ name: Build CLI on: - pull_request: + pull_request_target: + types: [opened, synchronize] branches-ignore: - 'l10n_master' - 'cf-pages' @@ -34,15 +35,23 @@ defaults: working-directory: apps/cli jobs: + check-run: + name: Check PR run + uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main + setup: name: Setup runs-on: ubuntu-22.04 + needs: + - check-run outputs: package_version: ${{ steps.retrieve-package-version.outputs.package_version }} node_version: ${{ steps.retrieve-node-version.outputs.node_version }} steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Get Package Version id: retrieve-package-version @@ -58,7 +67,6 @@ jobs: NODE_VERSION=${NODE_NVMRC/v/''} echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT - cli: name: "${{ matrix.os.base }} - ${{ matrix.license_type.readable }}" strategy: @@ -82,8 +90,10 @@ jobs: _WIN_PKG_FETCH_VERSION: 20.11.1 _WIN_PKG_VERSION: 3.5 steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Setup Unix Vars run: | @@ -160,8 +170,10 @@ jobs: _WIN_PKG_FETCH_VERSION: 20.11.1 _WIN_PKG_VERSION: 3.5 steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Setup Windows builder run: | @@ -310,8 +322,10 @@ jobs: env: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Print environment run: | @@ -389,7 +403,10 @@ jobs: steps: - name: Check if any job failed working-directory: ${{ github.workspace }} - if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && contains(needs.*.result, 'failure') + if: | + github.event_name != 'pull_request_target' + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc-cli') + && contains(needs.*.result, 'failure') run: exit 1 - name: Login to Azure - Prod Subscription diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 4667a937113..221c998247f 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -1,7 +1,8 @@ name: Build Desktop on: - pull_request: + pull_request_target: + types: [opened, synchronize] branches-ignore: - 'l10n_master' - 'cf-pages' @@ -32,12 +33,20 @@ defaults: shell: bash jobs: + check-run: + name: Check PR run + uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main + electron-verify: name: Verify Electron Version runs-on: ubuntu-22.04 + needs: + - check-run steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Verify run: | @@ -54,6 +63,8 @@ jobs: setup: name: Setup runs-on: ubuntu-22.04 + needs: + - check-run outputs: package_version: ${{ steps.retrieve-version.outputs.package_version }} release_channel: ${{ steps.release-channel.outputs.channel }} @@ -65,8 +76,10 @@ jobs: run: working-directory: apps/desktop steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Get Package Version id: retrieve-version @@ -138,8 +151,10 @@ jobs: run: working-directory: apps/desktop steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 @@ -238,7 +253,8 @@ jobs: windows: name: Windows Build runs-on: windows-2022 - needs: setup + needs: + - setup defaults: run: shell: pwsh @@ -248,8 +264,10 @@ jobs: _NODE_VERSION: ${{ needs.setup.outputs.node_version }} NODE_OPTIONS: --max_old_space_size=4096 steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 @@ -447,7 +465,8 @@ jobs: macos-build: name: MacOS Build runs-on: macos-13 - needs: setup + needs: + - setup env: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} _NODE_VERSION: ${{ needs.setup.outputs.node_version }} @@ -456,8 +475,10 @@ jobs: run: working-directory: apps/desktop steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 @@ -622,8 +643,10 @@ jobs: run: working-directory: apps/desktop steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 @@ -841,8 +864,10 @@ jobs: run: working-directory: apps/desktop steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 @@ -1033,9 +1058,8 @@ jobs: - name: Deploy to TestFlight id: testflight-deploy if: | - (github.ref == 'refs/heads/main' - || github.ref == 'refs/heads/rc' - || github.ref == 'refs/heads/hotfix-rc-desktop') + github.event_name != 'pull_request_target' + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc-desktop') env: APP_STORE_CONNECT_TEAM_ISSUER: ${{ secrets.APP_STORE_CONNECT_TEAM_ISSUER }} APP_STORE_CONNECT_AUTH_KEY: 6TV9MKN3GP @@ -1050,9 +1074,8 @@ jobs: - name: Post message to a Slack channel id: slack-message if: | - (github.ref == 'refs/heads/main' - || github.ref == 'refs/heads/rc' - || github.ref == 'refs/heads/hotfix-rc-desktop') + github.event_name != 'pull_request_target' + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc-desktop') uses: slackapi/slack-github-action@37ebaef184d7626c5f204ab8d3baff4262dd30f0 # v1.27.0 with: channel-id: C074F5UESQ0 @@ -1088,8 +1111,10 @@ jobs: run: working-directory: apps/desktop steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 @@ -1279,8 +1304,10 @@ jobs: - macos-package-mas runs-on: ubuntu-22.04 steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Login to Azure uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 @@ -1323,7 +1350,10 @@ jobs: - crowdin-push steps: - name: Check if any job failed - if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && contains(needs.*.result, 'failure') + if: | + github.event_name != 'pull_request_target' + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc-desktop') + && contains(needs.*.result, 'failure') run: exit 1 - name: Login to Azure - Prod Subscription diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 31f800d5b37..ba4f2599f37 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -1,7 +1,8 @@ name: Build Web on: - pull_request: + pull_request_target: + types: [opened, synchronize] branches-ignore: - 'l10n_master' - 'cf-pages' @@ -36,15 +37,23 @@ env: _AZ_REGISTRY: bitwardenprod.azurecr.io jobs: + check-run: + name: Check PR run + uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main + setup: name: Setup runs-on: ubuntu-22.04 + needs: + - check-run outputs: version: ${{ steps.version.outputs.value }} node_version: ${{ steps.retrieve-node-version.outputs.node_version }} steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Get GitHub sha as version id: version @@ -60,7 +69,8 @@ jobs: build-artifacts: name: Build artifacts runs-on: ubuntu-22.04 - needs: setup + needs: + - setup env: _VERSION: ${{ needs.setup.outputs.version }} _NODE_VERSION: ${{ needs.setup.outputs.node_version }} @@ -89,8 +99,10 @@ jobs: git_metadata: true steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 @@ -155,8 +167,10 @@ jobs: env: _VERSION: ${{ needs.setup.outputs.version }} steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Check Branch to Publish env: @@ -249,12 +263,15 @@ jobs: crowdin-push: name: Crowdin Push - if: github.ref == 'refs/heads/main' - needs: build-artifacts + if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' + needs: + - build-artifacts runs-on: ubuntu-22.04 steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Login to Azure uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 @@ -282,9 +299,10 @@ jobs: trigger-web-vault-deploy: name: Trigger web vault deploy - if: github.ref == 'refs/heads/main' + if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' runs-on: ubuntu-22.04 - needs: build-artifacts + needs: + - build-artifacts steps: - name: Login to Azure - CI Subscription uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 @@ -326,7 +344,10 @@ jobs: - trigger-web-vault-deploy steps: - name: Check if any job failed - if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && contains(needs.*.result, 'failure') + if: | + github.event_name != 'pull_request_target' + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc-web') + && contains(needs.*.result, 'failure') run: exit 1 - name: Login to Azure - Prod Subscription diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index 0686063d561..df664cf8d36 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -2878,7 +2878,7 @@ "message": "E-Mail generieren" }, "generatorBoundariesHint": { - "message": "Value must be between $MIN$ and $MAX$", + "message": "Wert muss zwischen $MIN$ und $MAX$ liegen", "description": "Explains spin box minimum and maximum values to the user", "placeholders": { "min": { diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 92fabdae3ee..a62dac05430 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -152,6 +152,15 @@ "copyLicenseNumber": { "message": "Copy license number" }, + "copyPrivateKey": { + "message": "Copy private key" + }, + "copyPublicKey": { + "message": "Copy public key" + }, + "copyFingerprint": { + "message": "Copy fingerprint" + }, "copyCustomField": { "message": "Copy $FIELD$", "placeholders": { @@ -1764,6 +1773,9 @@ "typeIdentity": { "message": "Identity" }, + "typeSshKey": { + "message": "SSH key" + }, "newItemHeader": { "message": "New $TYPE$", "placeholders": { @@ -4593,6 +4605,30 @@ "enterprisePolicyRequirementsApplied": { "message": "Enterprise policy requirements have been applied to this setting" }, + "sshPrivateKey": { + "message": "Private key" + }, + "sshPublicKey": { + "message": "Public key" + }, + "sshFingerprint": { + "message": "Fingerprint" + }, + "sshKeyAlgorithm": { + "message": "Key type" + }, + "sshKeyAlgorithmED25519": { + "message": "ED25519" + }, + "sshKeyAlgorithmRSA2048": { + "message": "RSA 2048-Bit" + }, + "sshKeyAlgorithmRSA3072": { + "message": "RSA 3072-Bit" + }, + "sshKeyAlgorithmRSA4096": { + "message": "RSA 4096-Bit" + }, "retry": { "message": "Retry" }, diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index cbb1475c4d0..42e56a56ba4 100644 --- a/apps/browser/src/_locales/es/messages.json +++ b/apps/browser/src/_locales/es/messages.json @@ -20,16 +20,16 @@ "message": "Crear cuenta" }, "newToBitwarden": { - "message": "New to Bitwarden?" + "message": "¿Nuevo en Bitwarden?" }, "logInWithPasskey": { - "message": "Log in with passkey" + "message": "Iniciar sesión con clave de acceso" }, "useSingleSignOn": { - "message": "Use single sign-on" + "message": "Usar inicio de sesión único" }, "welcomeBack": { - "message": "Welcome back" + "message": "Bienvenido de nuevo" }, "setAStrongPassword": { "message": "Establece una contraseña fuerte" @@ -84,7 +84,7 @@ "message": "Incorporarse a la organización" }, "joinOrganizationName": { - "message": "Join $ORGANIZATIONNAME$", + "message": "Unirse a $ORGANIZATIONNAME$", "placeholders": { "organizationName": { "content": "$1", @@ -120,7 +120,7 @@ "message": "Copiar contraseña" }, "copyPassphrase": { - "message": "Copy passphrase" + "message": "Copiar frase de contraseña" }, "copyNote": { "message": "Copiar nota" @@ -153,7 +153,7 @@ "message": "Copiar número de licencia" }, "copyCustomField": { - "message": "Copy $FIELD$", + "message": "Copiar $FIELD$", "placeholders": { "field": { "content": "$1", @@ -162,13 +162,13 @@ } }, "copyWebsite": { - "message": "Copy website" + "message": "Copiar sitio web" }, "copyNotes": { - "message": "Copy notes" + "message": "Copiar notas" }, "fill": { - "message": "Fill", + "message": "Rellenar", "description": "This string is used on the vault page to indicate autofilling. Horizontal space is limited in the interface here so try and keep translations as concise as possible." }, "autoFill": { @@ -223,13 +223,13 @@ "message": "Añadir elemento" }, "accountEmail": { - "message": "Account email" + "message": "Correo electrónico de la cuenta" }, "requestHint": { - "message": "Request hint" + "message": "Solicitar pista" }, "requestPasswordHint": { - "message": "Request password hint" + "message": "Solicitar pista de la contraseña" }, "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou": { "message": "Enter your account email address and your password hint will be sent to you" @@ -427,7 +427,7 @@ "message": "Generar contraseña" }, "generatePassphrase": { - "message": "Generate passphrase" + "message": "Generar frase de contraseña" }, "regeneratePassword": { "message": "Regenerar contraseña" @@ -567,7 +567,7 @@ "message": "Notas" }, "privateNote": { - "message": "Private note" + "message": "Nota privada" }, "note": { "message": "Nota" @@ -624,7 +624,7 @@ "message": "Tiempo de sesión agotado" }, "vaultTimeoutHeader": { - "message": "Vault timeout" + "message": "Tiempo de espera de la caja fuerte" }, "otherOptions": { "message": "Otras opciones" @@ -645,13 +645,13 @@ "message": "Tu caja fuerte está bloqueada. Verifica tu identidad para continuar." }, "yourVaultIsLockedV2": { - "message": "Your vault is locked" + "message": "Tu caja fuerte está bloqueada" }, "yourAccountIsLocked": { - "message": "Your account is locked" + "message": "Tu cuenta está bloqueada" }, "or": { - "message": "or" + "message": "o" }, "unlock": { "message": "Desbloquear" @@ -676,7 +676,7 @@ "message": "Tiempo de espera de la caja fuerte" }, "vaultTimeout1": { - "message": "Timeout" + "message": "Tiempo de espera" }, "lockNow": { "message": "Bloquear" @@ -4708,11 +4708,11 @@ "description": "Represents the - key in screen reader content as a readable word" }, "plusCharacterDescriptor": { - "message": "Plus", + "message": "Más", "description": "Represents the + key in screen reader content as a readable word" }, "equalsCharacterDescriptor": { - "message": "Equals", + "message": "Igual", "description": "Represents the = key in screen reader content as a readable word" }, "braceLeftCharacterDescriptor": { @@ -4736,15 +4736,15 @@ "description": "Represents the | key in screen reader content as a readable word" }, "backSlashCharacterDescriptor": { - "message": "Back slash", + "message": "Contrabarra", "description": "Represents the back slash key in screen reader content as a readable word" }, "colonCharacterDescriptor": { - "message": "Colon", + "message": "Dos puntos", "description": "Represents the : key in screen reader content as a readable word" }, "semicolonCharacterDescriptor": { - "message": "Semicolon", + "message": "Punto y coma", "description": "Represents the ; key in screen reader content as a readable word" }, "doubleQuoteCharacterDescriptor": { diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index 96a10c167a2..054362a4a26 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/messages.json @@ -1424,7 +1424,7 @@ "message": "Palvelimen URL" }, "selfHostBaseUrl": { - "message": "Self-host server URL", + "message": "Itse ylläpidetyn palvelimen URL-osoite", "description": "Label for field requesting a self-hosted integration service URL" }, "apiUrl": { @@ -1795,13 +1795,13 @@ "message": "Salasanahistoria" }, "generatorHistory": { - "message": "Generator history" + "message": "Generaattorihistoria" }, "clearGeneratorHistoryTitle": { - "message": "Clear generator history" + "message": "Tyhjennä generaattorihistoria" }, "cleargGeneratorHistoryDescription": { - "message": "If you continue, all entries will be permanently deleted from generator's history. Are you sure you want to continue?" + "message": "Jos jatkat, kaikki generaattorihistorian kohteet poistetaan. Haluatko varmasti jatkaa?" }, "back": { "message": "Takaisin" @@ -1920,7 +1920,7 @@ "message": "Tyhjennä historia" }, "nothingToShow": { - "message": "Nothing to show" + "message": "Mitään näytettävää ei ole" }, "nothingGeneratedRecently": { "message": "Et ole luonut mitään hiljattain" @@ -2710,7 +2710,7 @@ "description": "Used as a card title description on the set password page to explain why the user is there" }, "cardMetrics": { - "message": "out of $TOTAL$", + "message": "/$TOTAL$", "placeholders": { "total": { "content": "$1", diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index c05410956f2..e58a262d59c 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -147,7 +147,7 @@ "message": "Kopiera personnummer" }, "copyPassportNumber": { - "message": "Copy passport number" + "message": "Kopiera passnummer" }, "copyLicenseNumber": { "message": "Copy license number" @@ -4624,7 +4624,7 @@ "message": "Items that have been in trash more than 30 days will automatically be deleted" }, "restore": { - "message": "Restore" + "message": "Återställ" }, "deleteForever": { "message": "Delete forever" @@ -4744,7 +4744,7 @@ "description": "Represents the : key in screen reader content as a readable word" }, "semicolonCharacterDescriptor": { - "message": "Semicolon", + "message": "Semikolon", "description": "Represents the ; key in screen reader content as a readable word" }, "doubleQuoteCharacterDescriptor": { @@ -4756,11 +4756,11 @@ "description": "Represents the ' key in screen reader content as a readable word" }, "lessThanCharacterDescriptor": { - "message": "Less than", + "message": "Mindre än", "description": "Represents the < key in screen reader content as a readable word" }, "greaterThanCharacterDescriptor": { - "message": "Greater than", + "message": "Större än", "description": "Represents the > key in screen reader content as a readable word" }, "commaCharacterDescriptor": { @@ -4772,7 +4772,7 @@ "description": "Represents the . key in screen reader content as a readable word" }, "questionCharacterDescriptor": { - "message": "Question mark", + "message": "Frågetecken", "description": "Represents the ? key in screen reader content as a readable word" }, "forwardSlashCharacterDescriptor": { diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index 76d25262927..26bbbd0054f 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -168,7 +168,7 @@ "message": "複製備註" }, "fill": { - "message": "Fill", + "message": "填入", "description": "This string is used on the vault page to indicate autofilling. Horizontal space is limited in the interface here so try and keep translations as concise as possible." }, "autoFill": { @@ -458,7 +458,7 @@ "description": "deprecated. Use specialCharactersLabel instead." }, "include": { - "message": "Include", + "message": "包含", "description": "Card header for password generator include block" }, "uppercaseDescription": { @@ -730,10 +730,10 @@ "message": "安全" }, "confirmMasterPassword": { - "message": "Confirm master password" + "message": "確認主密碼" }, "masterPassword": { - "message": "Master password" + "message": "主密碼" }, "masterPassImportant": { "message": "Your master password cannot be recovered if you forget it!" @@ -1092,10 +1092,10 @@ "message": "This file export will be password protected and require the file password to decrypt." }, "filePassword": { - "message": "File password" + "message": "檔案密碼" }, "exportPasswordDescription": { - "message": "This password will be used to export and import this file" + "message": "此密碼將用於匯出和匯入此檔案" }, "accountRestrictedOptionDescription": { "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." @@ -3542,7 +3542,7 @@ "description": "Screen reader text (aria-label) for new item button in overlay" }, "newLogin": { - "message": "New login", + "message": "新增登入資訊", "description": "Button text to display within inline menu when there are no matching items on a login field" }, "addNewLoginItemAria": { diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.ts b/apps/browser/src/autofill/popup/settings/autofill.component.ts index b395808f57a..ac247609b13 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.ts +++ b/apps/browser/src/autofill/popup/settings/autofill.component.ts @@ -219,7 +219,11 @@ export class AutofillComponent implements OnInit { : AutofillOverlayVisibility.Off; await this.autofillSettingsService.setInlineMenuVisibility(newInlineMenuVisibilityValue); - await this.requestPrivacyPermission(); + + // No need to initiate browser permission request if a feature is being turned off + if (newInlineMenuVisibilityValue !== AutofillOverlayVisibility.Off) { + await this.requestPrivacyPermission(); + } } async updateAutofillOnPageLoad() { diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index 696bdb8b896..e79f6f69a36 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -436,9 +436,7 @@ export default class AutofillService implements AutofillServiceInterface { didAutofill = true; if (!options.skipLastUsed) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.cipherService.updateLastUsedDate(options.cipher.id); + await this.cipherService.updateLastUsedDate(options.cipher.id); } // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. diff --git a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts index 585f6067e3d..d1005883651 100644 --- a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts +++ b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts @@ -24,9 +24,9 @@ import { SendFormConfig, SendFormConfigService, SendFormMode, + SendFormModule, } from "@bitwarden/send-ui"; -import { SendFormModule } from "../../../../../../../libs/tools/send/send-ui/src/send-form/send-form.module"; import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component"; import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component"; diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts index 5febed788e7..8d95acbce9d 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 @@ -331,6 +331,8 @@ export class AddEditV2Component implements OnInit { return this.i18nService.t(partOne, this.i18nService.t("typeIdentity").toLocaleLowerCase()); case CipherType.SecureNote: return this.i18nService.t(partOne, this.i18nService.t("note").toLocaleLowerCase()); + case CipherType.SshKey: + return this.i18nService.t(partOne, this.i18nService.t("typeSshKey").toLocaleLowerCase()); } } } diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html index f4444a10aeb..973b1f9f1a4 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html @@ -88,3 +88,27 @@ [cipher]="cipher" > + + + + + + {{ "copyPrivateKey" | i18n }} + + + {{ "copyPublicKey" | i18n }} + + + {{ "copyFingerprint" | i18n }} + + + 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 a53c4a7c355..00a775024ce 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 @@ -48,5 +48,13 @@ export class ItemCopyActionsComponent { return !!this.cipher.notes; } + get hasSshKeyValues() { + return ( + !!this.cipher.sshKey.privateKey || + !!this.cipher.sshKey.publicKey || + !!this.cipher.sshKey.keyFingerprint + ); + } + constructor() {} } 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 f175c55c826..8ce3bcd2b60 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,7 +1,8 @@ import { CommonModule } from "@angular/common"; import { booleanAttribute, Component, Input, OnInit } from "@angular/core"; import { Router, RouterModule } from "@angular/router"; -import { firstValueFrom, map, Observable } from "rxjs"; +import { BehaviorSubject, firstValueFrom, map, switchMap } from "rxjs"; +import { filter } from "rxjs/operators"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; @@ -30,10 +31,18 @@ import { AddEditQueryParams } from "../add-edit/add-edit-v2.component"; imports: [ItemModule, IconButtonModule, MenuModule, CommonModule, JslibModule, RouterModule], }) export class ItemMoreOptionsComponent implements OnInit { + private _cipher$ = new BehaviorSubject(undefined); + @Input({ required: true, }) - cipher: CipherView; + set cipher(c: CipherView) { + this._cipher$.next(c); + } + + get cipher() { + return this._cipher$.value; + } /** * Flag to hide the autofill menu options. Used for items that are @@ -43,7 +52,15 @@ export class ItemMoreOptionsComponent implements OnInit { hideAutofillOptions: boolean; protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$; - protected canClone$: Observable; + + /** + * Observable that emits a boolean value indicating if the user is authorized to clone the cipher. + * @protected + */ + protected canClone$ = this._cipher$.pipe( + filter((c) => c != null), + switchMap((c) => this.cipherAuthorizationService.canCloneCipher$(c)), + ); /** Boolean dependent on the current user having access to an organization */ protected hasOrganizations = false; @@ -63,7 +80,6 @@ export class ItemMoreOptionsComponent implements OnInit { async ngOnInit(): Promise { this.hasOrganizations = await this.organizationService.hasOrganizations(); - this.canClone$ = this.cipherAuthorizationService.canCloneCipher$(this.cipher); } get canEdit() { 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 cc2c419c04c..b1cbe8bc3e4 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 @@ -131,6 +131,8 @@ export class ViewV2Component { ); case CipherType.SecureNote: return this.i18nService.t("viewItemHeader", this.i18nService.t("note").toLowerCase()); + case CipherType.SshKey: + return this.i18nService.t("viewItemHeader", this.i18nService.t("typeSshkey").toLowerCase()); } } diff --git a/apps/browser/src/vault/popup/components/vault/add-edit.component.html b/apps/browser/src/vault/popup/components/vault/add-edit.component.html index 32d7c283b28..fb1efbbbd79 100644 --- a/apps/browser/src/vault/popup/components/vault/add-edit.component.html +++ b/apps/browser/src/vault/popup/components/vault/add-edit.component.html @@ -529,6 +529,26 @@ /> + + + + + {{ "sshPrivateKey" | i18n }} + {{ cipher.sshKey.privateKey }} + + + {{ "sshPublicKey" | i18n }} + {{ cipher.sshKey.publicKey }} + + + {{ "sshKeyFingerprint" | i18n }} + {{ cipher.sshKey.keyFingerprint }} + + diff --git a/apps/browser/src/vault/popup/components/vault/vault-filter.component.html b/apps/browser/src/vault/popup/components/vault/vault-filter.component.html index f9ae340a89b..f5c28b2bebd 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-filter.component.html +++ b/apps/browser/src/vault/popup/components/vault/vault-filter.component.html @@ -114,6 +114,19 @@ {{ typeCounts.get(cipherType.SecureNote) || 0 }} + + + + {{ "typeSshKey" | i18n }} + + {{ typeCounts.get(cipherType.SshKey) || 0 }} + + diff --git a/apps/browser/src/vault/popup/components/vault/vault-items.component.ts b/apps/browser/src/vault/popup/components/vault/vault-items.component.ts index ee6858fe441..27d36cbc2f1 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-items.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-items.component.ts @@ -106,6 +106,9 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnIn case CipherType.SecureNote: this.groupingTitle = this.i18nService.t("secureNotes"); break; + case CipherType.SshKey: + this.groupingTitle = this.i18nService.t("sshKeys"); + break; default: break; } diff --git a/apps/browser/src/vault/popup/components/vault/view.component.html b/apps/browser/src/vault/popup/components/vault/view.component.html index 73415c9070a..57a5d007d8a 100644 --- a/apps/browser/src/vault/popup/components/vault/view.component.html +++ b/apps/browser/src/vault/popup/components/vault/view.component.html @@ -429,6 +429,39 @@ {{ cipher.identity.country }} + + + + + {{ "sshPrivateKey" | i18n }} + + + + + + {{ "sshPublicKey" | i18n }} + {{ cipher.sshKey.publicKey }} + + + + {{ "sshFingerprint" | i18n }} + {{ cipher.sshKey.keyFingerprint }} + + diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts index 42d76e1dfe7..610d48fdc6f 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts @@ -202,6 +202,7 @@ describe("VaultPopupItemsService", () => { [CipherType.Card]: 2, [CipherType.Identity]: 3, [CipherType.SecureNote]: 4, + [CipherType.SshKey]: 5, }; // Assume all ciphers are autofill ciphers to test sorting 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 09c7d5fb0db..20ac3b3de96 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 @@ -277,6 +277,7 @@ export class VaultPopupItemsService { [CipherType.Card]: 2, [CipherType.Identity]: 3, [CipherType.SecureNote]: 4, + [CipherType.SshKey]: 5, }; // Compare types first diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts index 5d7e690193b..02ad7375f6a 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts @@ -97,6 +97,7 @@ describe("VaultPopupListFiltersService", () => { CipherType.Card, CipherType.Identity, CipherType.SecureNote, + CipherType.SshKey, ]); }); }); diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts index 4059a43b56e..590807cff60 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts @@ -163,6 +163,11 @@ export class VaultPopupListFiltersService { label: this.i18nService.t("note"), icon: "bwi-sticky-note", }, + { + value: CipherType.SshKey, + label: this.i18nService.t("typeSshKey"), + icon: "bwi-key", + }, ]; /** Resets `filterForm` to the original state */ diff --git a/apps/cli/package.json b/apps/cli/package.json index 622c1273823..8ddb5daccd2 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -80,7 +80,7 @@ "papaparse": "5.4.1", "proper-lockfile": "4.1.2", "rxjs": "7.8.1", - "tldts": "6.1.58", + "tldts": "6.1.60", "zxcvbn": "4.4.2" } } diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index d8a7c470eba..a1ff96ec65d 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -17,6 +17,16 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aes" version = "0.8.4" @@ -28,6 +38,20 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -185,6 +209,28 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "async-stream" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-task" version = "4.7.1" @@ -235,12 +281,51 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + +[[package]] +name = "bcrypt-pbkdf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +dependencies = [ + "blowfish", + "pbkdf2", + "sha2", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +[[package]] +name = "bitwarden-russh" +version = "0.1.0" +source = "git+https://github.com/bitwarden/bitwarden-russh.git?branch=km/pm-10098/clean-russh-implementation#86ff1bf2f4620a3ae5684adee31abdbee33c6f07" +dependencies = [ + "anyhow", + "byteorder", + "futures", + "russh-cryptovec", + "ssh-encoding", + "ssh-key", + "thiserror", + "tokio", + "tokio-util", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -281,6 +366,16 @@ dependencies = [ "piper", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "byteorder" version = "1.5.0" @@ -339,6 +434,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "cipher" version = "0.4.4" @@ -377,6 +483,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "convert_case" version = "0.6.0" @@ -388,9 +500,9 @@ dependencies = [ [[package]] name = "core-foundation" -version = "0.9.4" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" dependencies = [ "core-foundation-sys", "libc", @@ -437,6 +549,41 @@ dependencies = [ "syn", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "cxx" version = "1.0.129" @@ -481,6 +628,17 @@ dependencies = [ "syn", ] +[[package]] +name = "der" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.3.11" @@ -508,25 +666,38 @@ dependencies = [ "aes", "anyhow", "arboard", + "async-stream", "base64", + "bitwarden-russh", + "byteorder", "cbc", "core-foundation", "dirs", + "ed25519", "futures", "gio", + "homedir", "interprocess", "keytar", "libc", "libsecret", "log", + "pin-project", + "pkcs8", "rand", + "rand_chacha", "retry", + "rsa", + "russh-cryptovec", "scopeguard", "security-framework", "security-framework-sys", "sha2", + "ssh-encoding", + "ssh-key", "thiserror", "tokio", + "tokio-stream", "tokio-util", "typenum", "widestring", @@ -540,11 +711,14 @@ name = "desktop_napi" version = "0.0.0" dependencies = [ "anyhow", + "base64", "desktop_core", + "hex", "napi", "napi-build", "napi-derive", "tokio", + "tokio-stream", "tokio-util", "windows-registry", ] @@ -570,7 +744,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", ] [[package]] @@ -615,6 +791,28 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2", + "subtle", +] + [[package]] name = "embed_plist" version = "1.2.2" @@ -697,6 +895,12 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "fixedbitset" version = "0.4.2" @@ -842,6 +1046,16 @@ dependencies = [ "wasi", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.31.1" @@ -885,7 +1099,7 @@ version = "0.19.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39650279f135469465018daae0ba53357942a5212137515777d5fdca74984a44" dependencies = [ - "bitflags", + "bitflags 2.6.0", "futures-channel", "futures-core", "futures-executor", @@ -965,6 +1179,27 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "homedir" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bed305c13ce3829a09d627f5d43ff738482a09361ae4eb8039993b55fb10e5e" +dependencies = [ + "cfg-if", + "nix 0.26.4", + "widestring", + "windows", +] + [[package]] name = "indexmap" version = "2.6.0" @@ -1028,10 +1263,19 @@ dependencies = [ ] [[package]] -name = "libc" -version = "0.2.159" +name = "lazy_static" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.162" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d287de67fe55fd7e1581fe933d965a5a9477b38e949cfa9f8574ef01506398" [[package]] name = "libloading" @@ -1043,13 +1287,19 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "libm" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" + [[package]] name = "libredox" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ - "bitflags", + "bitflags 2.6.0", "libc", ] @@ -1116,6 +1366,15 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -1158,7 +1417,7 @@ version = "2.16.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "214f07a80874bb96a8433b3cdfc84980d56c7b02e1a0d7ba4ba0db5cef785e2b" dependencies = [ - "bitflags", + "bitflags 2.6.0", "ctor", "napi-derive", "napi-sys", @@ -1210,13 +1469,26 @@ dependencies = [ "libloading", ] +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.7.1", + "pin-utils", +] + [[package]] name = "nix" version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags", + "bitflags 2.6.0", "cfg-if", "cfg_aliases 0.1.1", "libc", @@ -1228,11 +1500,11 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags", + "bitflags 2.6.0", "cfg-if", "cfg_aliases 0.2.1", "libc", - "memoffset", + "memoffset 0.9.1", ] [[package]] @@ -1245,12 +1517,59 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + [[package]] name = "num_threads" version = "0.1.7" @@ -1282,7 +1601,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags", + "bitflags 2.6.0", "block2", "libc", "objc2", @@ -1298,7 +1617,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags", + "bitflags 2.6.0", "block2", "objc2", "objc2-foundation", @@ -1328,7 +1647,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags", + "bitflags 2.6.0", "block2", "libc", "objc2", @@ -1340,7 +1659,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags", + "bitflags 2.6.0", "block2", "objc2", "objc2-foundation", @@ -1352,7 +1671,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags", + "bitflags 2.6.0", "block2", "objc2", "objc2-foundation", @@ -1374,6 +1693,12 @@ version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "option-ext" version = "0.2.0" @@ -1429,6 +1754,25 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "petgraph" version = "0.6.5" @@ -1439,6 +1783,26 @@ dependencies = [ "indexmap", ] +[[package]] +name = "pin-project" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.15" @@ -1462,6 +1826,44 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs5" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" +dependencies = [ + "aes", + "cbc", + "der", + "pbkdf2", + "scrypt", + "sha2", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "pkcs5", + "rand_core", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.31" @@ -1483,6 +1885,29 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1576,7 +2001,7 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" dependencies = [ - "bitflags", + "bitflags 2.6.0", ] [[package]] @@ -1628,25 +2053,74 @@ dependencies = [ "rand", ] +[[package]] +name = "rsa" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "russh-cryptovec" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fadd2c0ab350e21c66556f94ee06f766d8bdae3213857ba7610bfd8e10e51880" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "rustc-demangle" version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" dependencies = [ - "bitflags", + "bitflags 2.6.0", "errno", "libc", "linux-raw-sys", "windows-sys 0.52.0", ] +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -1666,12 +2140,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3cf7c11c38cb994f3d40e8a8cde3bbd1f72a435e4c49e85d6553d8312306152" [[package]] -name = "security-framework" -version = "2.11.0" +name = "scrypt" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c627723fd09706bacdb5cf41499e95098555af3c3c29d014dc3c458ef6be11c0" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" dependencies = [ - "bitflags", + "pbkdf2", + "salsa20", + "sha2", +] + +[[package]] +name = "security-framework" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d0283c0a4a22a0f1b0e4edca251aa20b92fc96eaa09b84bec052f9415e9d71" +dependencies = [ + "bitflags 2.6.0", "core-foundation", "core-foundation-sys", "libc", @@ -1680,9 +2165,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317936bbbd05227752583946b9e66d7ce3b489f84e11a94a510b4437fef407d7" +checksum = "ea4a292869320c0272d7bc55a5a6aafaff59b4f63404a003887b679a2e05b4b6" dependencies = [ "core-foundation-sys", "libc", @@ -1771,6 +2256,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + [[package]] name = "simplelog" version = "0.12.2" @@ -1807,12 +2302,81 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "aes", + "aes-gcm", + "cbc", + "chacha20", + "cipher", + "ctr", + "poly1305", + "ssh-encoding", + "subtle", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "ssh-key" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca9b366a80cf18bb6406f4cf4d10aebfb46140a8c0c33f666a144c5c76ecbafc" +dependencies = [ + "bcrypt-pbkdf", + "ed25519-dalek", + "num-bigint-dig", + "rand_core", + "rsa", + "sha2", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + [[package]] name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.87" @@ -1920,9 +2484,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.41.0" +version = "1.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145f3413504347a2be84393cc8a7d2fb4d863b375909ea59f2158261aa258bbb" +checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" dependencies = [ "backtrace", "bytes", @@ -1945,6 +2509,17 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-stream" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267ac89e0bec6e691e5813911606935d77c476ff49024f98abcea3e7b15e37af" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.12" @@ -2048,7 +2623,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" dependencies = [ - "memoffset", + "memoffset 0.9.1", "tempfile", "winapi", ] @@ -2071,6 +2646,16 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "version-compare" version = "0.2.0" @@ -2109,7 +2694,7 @@ version = "0.31.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b66249d3fc69f76fd74c82cc319300faa554e9d865dab1f7cd66cc20db10b280" dependencies = [ - "bitflags", + "bitflags 2.6.0", "rustix", "wayland-backend", "wayland-scanner", @@ -2121,7 +2706,7 @@ version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4" dependencies = [ - "bitflags", + "bitflags 2.6.0", "wayland-backend", "wayland-client", "wayland-scanner", @@ -2133,7 +2718,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad1f61b76b6c2d8742e10f9ba5c3737f6530b4c243132c2a2ccc8aa96fe25cd6" dependencies = [ - "bitflags", + "bitflags 2.6.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -2581,6 +3166,12 @@ dependencies = [ "syn", ] +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + [[package]] name = "zvariant" version = "4.2.0" diff --git a/apps/desktop/desktop_native/core/Cargo.toml b/apps/desktop/desktop_native/core/Cargo.toml index a56c1b57434..7ed708fc577 100644 --- a/apps/desktop/desktop_native/core/Cargo.toml +++ b/apps/desktop/desktop_native/core/Cargo.toml @@ -27,21 +27,39 @@ anyhow = "=1.0.93" arboard = { version = "=3.4.1", default-features = false, features = [ "wayland-data-control", ] } +async-stream = "0.3.5" base64 = "=0.22.1" +byteorder = "1.5.0" cbc = { version = "=0.1.2", features = ["alloc"] } +homedir = "0.3.3" +libc = "=0.2.162" +pin-project = "1.1.5" dirs = "=5.0.1" futures = "=0.3.31" interprocess = { version = "=2.2.1", features = ["tokio"] } -libc = "=0.2.159" log = "=0.4.22" rand = "=0.8.5" retry = "=2.0.0" +russh-cryptovec = "0.7.3" scopeguard = "=1.2.0" sha2 = "=0.10.8" -thiserror = "=1.0.68" -tokio = { version = "=1.41.0", features = ["io-util", "sync", "macros"] } +ssh-encoding = "0.2.0" +ssh-key = { version = "0.6.6", default-features = false, features = [ + "encryption", + "ed25519", + "rsa", + "getrandom", +] } +bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", branch = "km/pm-10098/clean-russh-implementation" } +tokio = { version = "=1.40.0", features = ["io-util", "sync", "macros", "net"] } +tokio-stream = { version = "=0.1.15", features = ["net"] } tokio-util = "=0.7.12" +thiserror = "=1.0.68" typenum = "=1.17.0" +rand_chacha = "=0.3.1" +pkcs8 = { version = "=0.10.2", features = ["alloc", "encryption", "pem"] } +rsa = "=0.9.6" +ed25519 = { version = "=2.2.3", features = ["pkcs8"] } [target.'cfg(windows)'.dependencies] widestring = { version = "=1.1.0", optional = true } @@ -61,9 +79,9 @@ windows = { version = "=0.57.0", features = [ keytar = "=0.1.6" [target.'cfg(target_os = "macos")'.dependencies] -core-foundation = { version = "=0.9.4", optional = true } -security-framework = { version = "=2.11.0", optional = true } -security-framework-sys = { version = "=2.11.0", optional = true } +core-foundation = { version = "=0.10.0", optional = true } +security-framework = { version = "=3.0.0", optional = true } +security-framework-sys = { version = "=2.12.0", optional = true } [target.'cfg(target_os = "linux")'.dependencies] gio = { version = "=0.19.5", optional = true } diff --git a/apps/desktop/desktop_native/core/src/biometric/mod.rs b/apps/desktop/desktop_native/core/src/biometric/mod.rs index c41ad9dda53..72352cf2288 100644 --- a/apps/desktop/desktop_native/core/src/biometric/mod.rs +++ b/apps/desktop/desktop_native/core/src/biometric/mod.rs @@ -6,8 +6,8 @@ use anyhow::{anyhow, Result}; #[cfg_attr(target_os = "macos", path = "macos.rs")] mod biometric; -pub use biometric::Biometric; use base64::{engine::general_purpose::STANDARD as base64_engine, Engine}; +pub use biometric::Biometric; use sha2::{Digest, Sha256}; use crate::crypto::{self, CipherString}; @@ -42,7 +42,6 @@ pub trait BiometricTrait { ) -> Result; } - fn encrypt(secret: &str, key_material: &KeyMaterial, iv_b64: &str) -> Result { let iv = base64_engine .decode(iv_b64)? @@ -77,4 +76,4 @@ impl KeyMaterial { pub fn derive_key(&self) -> Result> { Ok(Sha256::digest(self.digest_material())) } -} \ No newline at end of file +} diff --git a/apps/desktop/desktop_native/core/src/biometric/unix.rs b/apps/desktop/desktop_native/core/src/biometric/unix.rs index 742b736e812..563bd1dfe52 100644 --- a/apps/desktop/desktop_native/core/src/biometric/unix.rs +++ b/apps/desktop/desktop_native/core/src/biometric/unix.rs @@ -5,13 +5,13 @@ use base64::Engine; use rand::RngCore; use sha2::{Digest, Sha256}; -use crate::biometric::{KeyMaterial, OsDerivedKey, base64_engine}; +use crate::biometric::{base64_engine, KeyMaterial, OsDerivedKey}; use zbus::Connection; use zbus_polkit::policykit1::*; use super::{decrypt, encrypt}; -use anyhow::anyhow; use crate::crypto::CipherString; +use anyhow::anyhow; /// The Unix implementation of the biometric trait. pub struct Biometric {} @@ -22,13 +22,15 @@ impl super::BiometricTrait for Biometric { let proxy = AuthorityProxy::new(&connection).await?; let subject = Subject::new_for_owner(std::process::id(), None, None)?; let details = std::collections::HashMap::new(); - let result = proxy.check_authorization( - &subject, - "com.bitwarden.Bitwarden.unlock", - &details, - CheckAuthorizationFlags::AllowUserInteraction.into(), - "", - ).await; + let result = proxy + .check_authorization( + &subject, + "com.bitwarden.Bitwarden.unlock", + &details, + CheckAuthorizationFlags::AllowUserInteraction.into(), + "", + ) + .await; match result { Ok(result) => { @@ -106,4 +108,4 @@ fn random_challenge() -> [u8; 16] { let mut challenge = [0u8; 16]; rand::thread_rng().fill_bytes(&mut challenge); challenge -} \ No newline at end of file +} diff --git a/apps/desktop/desktop_native/core/src/biometric/windows.rs b/apps/desktop/desktop_native/core/src/biometric/windows.rs index c5db9e3277b..d5e8b6dc915 100644 --- a/apps/desktop/desktop_native/core/src/biometric/windows.rs +++ b/apps/desktop/desktop_native/core/src/biometric/windows.rs @@ -160,7 +160,6 @@ impl super::BiometricTrait for Biometric { } } - fn random_challenge() -> [u8; 16] { let mut challenge = [0u8; 16]; rand::thread_rng().fill_bytes(&mut challenge); diff --git a/apps/desktop/desktop_native/core/src/lib.rs b/apps/desktop/desktop_native/core/src/lib.rs index 3132c56f7f8..f38e6ef97b4 100644 --- a/apps/desktop/desktop_native/core/src/lib.rs +++ b/apps/desktop/desktop_native/core/src/lib.rs @@ -11,3 +11,6 @@ pub mod password; pub mod process_isolation; #[cfg(feature = "sys")] pub mod powermonitor; +#[cfg(feature = "sys")] + +pub mod ssh_agent; diff --git a/apps/desktop/desktop_native/core/src/password/unix.rs b/apps/desktop/desktop_native/core/src/password/unix.rs index 53053ee467e..1817a4d62ee 100644 --- a/apps/desktop/desktop_native/core/src/password/unix.rs +++ b/apps/desktop/desktop_native/core/src/password/unix.rs @@ -41,7 +41,11 @@ pub fn delete_password(service: &str, account: &str) -> Result<()> { } pub fn is_available() -> Result { - let result = password_clear_sync(Some(&get_schema()), build_attributes("bitwardenSecretsAvailabilityTest", "test"), gio::Cancellable::NONE); + let result = password_clear_sync( + Some(&get_schema()), + build_attributes("bitwardenSecretsAvailabilityTest", "test"), + gio::Cancellable::NONE, + ); match result { Ok(_) => Ok(true), Err(_) => { diff --git a/apps/desktop/desktop_native/core/src/powermonitor/linux.rs b/apps/desktop/desktop_native/core/src/powermonitor/linux.rs index fe07ad11ff8..7d0fde15ed4 100644 --- a/apps/desktop/desktop_native/core/src/powermonitor/linux.rs +++ b/apps/desktop/desktop_native/core/src/powermonitor/linux.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; -use zbus::{Connection, MatchRule, export::futures_util::TryStreamExt}; +use zbus::{export::futures_util::TryStreamExt, Connection, MatchRule}; struct ScreenLock { interface: Cow<'static, str>, path: Cow<'static, str>, @@ -42,7 +42,15 @@ pub async fn on_lock(tx: tokio::sync::mpsc::Sender<()>) -> Result<(), Box bool { let connection = Connection::session().await.unwrap(); for monitor in SCREEN_LOCK_MONITORS { - let res = connection.call_method(Some(monitor.interface.clone()), monitor.path.clone(), Some(monitor.interface.clone()), "GetActive", &()).await; + let res = connection + .call_method( + Some(monitor.interface.clone()), + monitor.path.clone(), + Some(monitor.interface.clone()), + "GetActive", + &(), + ) + .await; if res.is_ok() { return true; } diff --git a/apps/desktop/desktop_native/core/src/process_isolation/linux.rs b/apps/desktop/desktop_native/core/src/process_isolation/linux.rs index ba8734cff7f..dc027e0b546 100644 --- a/apps/desktop/desktop_native/core/src/process_isolation/linux.rs +++ b/apps/desktop/desktop_native/core/src/process_isolation/linux.rs @@ -1,7 +1,7 @@ use anyhow::Result; -use libc::{c_int, self}; #[cfg(target_env = "gnu")] use libc::c_uint; +use libc::{self, c_int}; // RLIMIT_CORE is the maximum size of a core dump file. Setting both to 0 disables core dumps, on crashes // https://github.com/torvalds/linux/blob/1613e604df0cd359cf2a7fbd9be7a0bcfacfabd0/include/uapi/asm-generic/resource.h#L20 @@ -22,7 +22,10 @@ pub fn disable_coredumps() -> Result<()> { }; if unsafe { libc::setrlimit(RLIMIT_CORE, &rlimit) } != 0 { let e = std::io::Error::last_os_error(); - return Err(anyhow::anyhow!("failed to disable core dumping, memory might be persisted to disk on crashes {}", e)) + return Err(anyhow::anyhow!( + "failed to disable core dumping, memory might be persisted to disk on crashes {}", + e + )); } Ok(()) @@ -35,7 +38,7 @@ pub fn is_core_dumping_disabled() -> Result { }; if unsafe { libc::getrlimit(RLIMIT_CORE, &mut rlimit) } != 0 { let e = std::io::Error::last_os_error(); - return Err(anyhow::anyhow!("failed to get core dump limit {}", e)) + return Err(anyhow::anyhow!("failed to get core dump limit {}", e)); } Ok(rlimit.rlim_cur == 0 && rlimit.rlim_max == 0) @@ -44,7 +47,10 @@ pub fn is_core_dumping_disabled() -> Result { pub fn disable_memory_access() -> Result<()> { if unsafe { libc::prctl(PR_SET_DUMPABLE, 0) } != 0 { let e = std::io::Error::last_os_error(); - return Err(anyhow::anyhow!("failed to disable memory dumping, memory is dumpable by other processes {}", e)) + return Err(anyhow::anyhow!( + "failed to disable memory dumping, memory is dumpable by other processes {}", + e + )); } Ok(()) diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/generator.rs b/apps/desktop/desktop_native/core/src/ssh_agent/generator.rs new file mode 100644 index 00000000000..fe639f20e7f --- /dev/null +++ b/apps/desktop/desktop_native/core/src/ssh_agent/generator.rs @@ -0,0 +1,45 @@ +use rand::SeedableRng; +use rand_chacha::ChaCha8Rng; +use ssh_key::{Algorithm, HashAlg, LineEnding}; + +use super::importer::SshKey; + +pub async fn generate_keypair(key_algorithm: String) -> Result { + // sourced from cryptographically secure entropy source, with sources for all targets: https://docs.rs/getrandom + // if it cannot be securely sourced, this will panic instead of leading to a weak key + let mut rng: ChaCha8Rng = ChaCha8Rng::from_entropy(); + + let key = match key_algorithm.as_str() { + "ed25519" => ssh_key::PrivateKey::random(&mut rng, Algorithm::Ed25519), + "rsa2048" | "rsa3072" | "rsa4096" => { + let bits = match key_algorithm.as_str() { + "rsa2048" => 2048, + "rsa3072" => 3072, + "rsa4096" => 4096, + _ => return Err(anyhow::anyhow!("Unsupported RSA key size")), + }; + let rsa_keypair = ssh_key::private::RsaKeypair::random(&mut rng, bits) + .or_else(|e| Err(anyhow::anyhow!(e.to_string())))?; + + let private_key = ssh_key::PrivateKey::new( + ssh_key::private::KeypairData::from(rsa_keypair), + "".to_string(), + ) + .or_else(|e| Err(anyhow::anyhow!(e.to_string())))?; + Ok(private_key) + } + _ => { + return Err(anyhow::anyhow!("Unsupported key algorithm")); + } + } + .or_else(|e| Err(anyhow::anyhow!(e.to_string())))?; + + let private_key_openssh = key + .to_openssh(LineEnding::LF) + .or_else(|e| Err(anyhow::anyhow!(e.to_string())))?; + Ok(SshKey { + private_key: private_key_openssh.to_string(), + public_key: key.public_key().to_string(), + key_fingerprint: key.fingerprint(HashAlg::Sha256).to_string(), + }) +} diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/importer.rs b/apps/desktop/desktop_native/core/src/ssh_agent/importer.rs new file mode 100644 index 00000000000..3d643e764c7 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/ssh_agent/importer.rs @@ -0,0 +1,395 @@ +use ed25519; +use pkcs8::{ + der::Decode, EncryptedPrivateKeyInfo, ObjectIdentifier, PrivateKeyInfo, SecretDocument, +}; +use ssh_key::{ + private::{Ed25519Keypair, Ed25519PrivateKey, RsaKeypair}, + HashAlg, LineEnding, +}; + +const PKCS1_HEADER: &str = "-----BEGIN RSA PRIVATE KEY-----"; +const PKCS8_UNENCRYPTED_HEADER: &str = "-----BEGIN PRIVATE KEY-----"; +const PKCS8_ENCRYPTED_HEADER: &str = "-----BEGIN ENCRYPTED PRIVATE KEY-----"; +const OPENSSH_HEADER: &str = "-----BEGIN OPENSSH PRIVATE KEY-----"; + +pub const RSA_PKCS8_ALGORITHM_OID: ObjectIdentifier = + ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.1"); + +#[derive(Debug)] +enum KeyType { + Ed25519, + Rsa, + Unknown, +} + +pub fn import_key( + encoded_key: String, + password: String, +) -> Result { + match encoded_key.lines().next() { + Some(PKCS1_HEADER) => { + return Ok(SshKeyImportResult { + status: SshKeyImportStatus::UnsupportedKeyType, + ssh_key: None, + }); + } + Some(PKCS8_UNENCRYPTED_HEADER) => { + return match import_pkcs8_key(encoded_key, None) { + Ok(result) => Ok(result), + Err(_) => Ok(SshKeyImportResult { + status: SshKeyImportStatus::ParsingError, + ssh_key: None, + }), + }; + } + Some(PKCS8_ENCRYPTED_HEADER) => match import_pkcs8_key(encoded_key, Some(password)) { + Ok(result) => { + return Ok(result); + } + Err(err) => match err { + SshKeyImportError::PasswordRequired => { + return Ok(SshKeyImportResult { + status: SshKeyImportStatus::PasswordRequired, + ssh_key: None, + }); + } + SshKeyImportError::WrongPassword => { + return Ok(SshKeyImportResult { + status: SshKeyImportStatus::WrongPassword, + ssh_key: None, + }); + } + SshKeyImportError::ParsingError => { + return Ok(SshKeyImportResult { + status: SshKeyImportStatus::ParsingError, + ssh_key: None, + }); + } + }, + }, + Some(OPENSSH_HEADER) => { + return import_openssh_key(encoded_key, password); + } + Some(_) => { + return Ok(SshKeyImportResult { + status: SshKeyImportStatus::ParsingError, + ssh_key: None, + }); + } + None => { + return Ok(SshKeyImportResult { + status: SshKeyImportStatus::ParsingError, + ssh_key: None, + }); + } + } +} + +fn import_pkcs8_key( + encoded_key: String, + password: Option, +) -> Result { + let der = match SecretDocument::from_pem(&encoded_key) { + Ok((_, doc)) => doc, + Err(_) => { + return Ok(SshKeyImportResult { + status: SshKeyImportStatus::ParsingError, + ssh_key: None, + }); + } + }; + + let decrypted_der = match password.clone() { + Some(password) => { + let encrypted_private_key_info = match EncryptedPrivateKeyInfo::from_der(der.as_bytes()) + { + Ok(info) => info, + Err(_) => { + return Ok(SshKeyImportResult { + status: SshKeyImportStatus::ParsingError, + ssh_key: None, + }); + } + }; + match encrypted_private_key_info.decrypt(password.as_bytes()) { + Ok(der) => der, + Err(_) => { + return Ok(SshKeyImportResult { + status: SshKeyImportStatus::WrongPassword, + ssh_key: None, + }); + } + } + } + None => der, + }; + + let key_type: KeyType = match PrivateKeyInfo::from_der(decrypted_der.as_bytes()) + .map_err(|_| SshKeyImportError::ParsingError)? + .algorithm + .oid + { + ed25519::pkcs8::ALGORITHM_OID => KeyType::Ed25519, + RSA_PKCS8_ALGORITHM_OID => KeyType::Rsa, + _ => KeyType::Unknown, + }; + + match key_type { + KeyType::Ed25519 => { + let pk: ed25519::KeypairBytes = match password { + Some(password) => { + pkcs8::DecodePrivateKey::from_pkcs8_encrypted_pem(&encoded_key, password) + .map_err(|err| match err { + ed25519::pkcs8::Error::EncryptedPrivateKey(_) => { + SshKeyImportError::WrongPassword + } + _ => SshKeyImportError::ParsingError, + })? + } + None => ed25519::pkcs8::DecodePrivateKey::from_pkcs8_pem(&encoded_key) + .map_err(|_| SshKeyImportError::ParsingError)?, + }; + let pk: Ed25519Keypair = + Ed25519Keypair::from(Ed25519PrivateKey::from_bytes(&pk.secret_key)); + let private_key = ssh_key::private::PrivateKey::from(pk); + return Ok(SshKeyImportResult { + status: SshKeyImportStatus::Success, + ssh_key: Some(SshKey { + private_key: private_key.to_openssh(LineEnding::LF).unwrap().to_string(), + public_key: private_key.public_key().to_string(), + key_fingerprint: private_key.fingerprint(HashAlg::Sha256).to_string(), + }), + }); + } + KeyType::Rsa => { + let pk: rsa::RsaPrivateKey = match password { + Some(password) => { + pkcs8::DecodePrivateKey::from_pkcs8_encrypted_pem(&encoded_key, password) + .map_err(|err| match err { + pkcs8::Error::EncryptedPrivateKey(_) => { + SshKeyImportError::WrongPassword + } + _ => SshKeyImportError::ParsingError, + })? + } + None => pkcs8::DecodePrivateKey::from_pkcs8_pem(&encoded_key) + .map_err(|_| SshKeyImportError::ParsingError)?, + }; + let rsa_keypair: Result = RsaKeypair::try_from(pk); + match rsa_keypair { + Ok(rsa_keypair) => { + let private_key = ssh_key::private::PrivateKey::from(rsa_keypair); + return Ok(SshKeyImportResult { + status: SshKeyImportStatus::Success, + ssh_key: Some(SshKey { + private_key: private_key + .to_openssh(LineEnding::LF) + .unwrap() + .to_string(), + public_key: private_key.public_key().to_string(), + key_fingerprint: private_key.fingerprint(HashAlg::Sha256).to_string(), + }), + }); + } + Err(_) => { + return Ok(SshKeyImportResult { + status: SshKeyImportStatus::ParsingError, + ssh_key: None, + }); + } + } + } + _ => { + return Ok(SshKeyImportResult { + status: SshKeyImportStatus::UnsupportedKeyType, + ssh_key: None, + }); + } + } +} + +fn import_openssh_key( + encoded_key: String, + password: String, +) -> Result { + let private_key = ssh_key::private::PrivateKey::from_openssh(&encoded_key); + let private_key = match private_key { + Ok(k) => k, + Err(err) => { + match err { + ssh_key::Error::AlgorithmUnknown + | ssh_key::Error::AlgorithmUnsupported { algorithm: _ } => { + return Ok(SshKeyImportResult { + status: SshKeyImportStatus::UnsupportedKeyType, + ssh_key: None, + }); + } + _ => {} + } + return Ok(SshKeyImportResult { + status: SshKeyImportStatus::ParsingError, + ssh_key: None, + }); + } + }; + + if private_key.is_encrypted() && password.is_empty() { + return Ok(SshKeyImportResult { + status: SshKeyImportStatus::PasswordRequired, + ssh_key: None, + }); + } + let private_key = if private_key.is_encrypted() { + match private_key.decrypt(password.as_bytes()) { + Ok(k) => k, + Err(_) => { + return Ok(SshKeyImportResult { + status: SshKeyImportStatus::WrongPassword, + ssh_key: None, + }); + } + } + } else { + private_key + }; + + match private_key.to_openssh(LineEnding::LF) { + Ok(private_key_openssh) => Ok(SshKeyImportResult { + status: SshKeyImportStatus::Success, + ssh_key: Some(SshKey { + private_key: private_key_openssh.to_string(), + public_key: private_key.public_key().to_string(), + key_fingerprint: private_key.fingerprint(HashAlg::Sha256).to_string(), + }), + }), + Err(_) => Ok(SshKeyImportResult { + status: SshKeyImportStatus::ParsingError, + ssh_key: None, + }), + } +} + +#[derive(PartialEq, Debug)] +pub enum SshKeyImportStatus { + /// ssh key was parsed correctly and will be returned in the result + Success, + /// ssh key was parsed correctly but is encrypted and requires a password + PasswordRequired, + /// ssh key was parsed correctly, and a password was provided when calling the import, but it was incorrect + WrongPassword, + /// ssh key could not be parsed, either due to an incorrect / unsupported format (pkcs#8) or key type (ecdsa), or because the input is not an ssh key + ParsingError, + /// ssh key type is not supported + UnsupportedKeyType, +} + +pub enum SshKeyImportError { + ParsingError, + PasswordRequired, + WrongPassword, +} + +pub struct SshKeyImportResult { + pub status: SshKeyImportStatus, + pub ssh_key: Option, +} + +pub struct SshKey { + pub private_key: String, + pub public_key: String, + pub key_fingerprint: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn import_key_ed25519_openssh_unencrypted() { + let private_key = include_str!("./test_keys/ed25519_openssh_unencrypted"); + let public_key = include_str!("./test_keys/ed25519_openssh_unencrypted.pub").trim(); + let result = import_key(private_key.to_string(), "".to_string()).unwrap(); + assert_eq!(result.status, SshKeyImportStatus::Success); + assert_eq!(result.ssh_key.unwrap().public_key, public_key); + } + + #[test] + fn import_key_ed25519_openssh_encrypted() { + let private_key = include_str!("./test_keys/ed25519_openssh_encrypted"); + let public_key = include_str!("./test_keys/ed25519_openssh_encrypted.pub").trim(); + let result = import_key(private_key.to_string(), "password".to_string()).unwrap(); + assert_eq!(result.status, SshKeyImportStatus::Success); + assert_eq!(result.ssh_key.unwrap().public_key, public_key); + } + + #[test] + fn import_key_rsa_openssh_unencrypted() { + let private_key = include_str!("./test_keys/rsa_openssh_unencrypted"); + let public_key = include_str!("./test_keys/rsa_openssh_unencrypted.pub").trim(); + let result = import_key(private_key.to_string(), "".to_string()).unwrap(); + assert_eq!(result.status, SshKeyImportStatus::Success); + assert_eq!(result.ssh_key.unwrap().public_key, public_key); + } + + #[test] + fn import_key_rsa_openssh_encrypted() { + let private_key = include_str!("./test_keys/rsa_openssh_encrypted"); + let public_key = include_str!("./test_keys/rsa_openssh_encrypted.pub").trim(); + let result = import_key(private_key.to_string(), "password".to_string()).unwrap(); + assert_eq!(result.status, SshKeyImportStatus::Success); + assert_eq!(result.ssh_key.unwrap().public_key, public_key); + } + + #[test] + fn import_key_ed25519_pkcs8_unencrypted() { + let private_key = include_str!("./test_keys/ed25519_pkcs8_unencrypted"); + let public_key = + include_str!("./test_keys/ed25519_pkcs8_unencrypted.pub").replace("testkey", ""); + let public_key = public_key.trim(); + let result = import_key(private_key.to_string(), "".to_string()).unwrap(); + assert_eq!(result.status, SshKeyImportStatus::Success); + assert_eq!(result.ssh_key.unwrap().public_key, public_key); + } + + #[test] + fn import_key_rsa_pkcs8_unencrypted() { + let private_key = include_str!("./test_keys/rsa_pkcs8_unencrypted"); + // for whatever reason pkcs8 + rsa does not include the comment in the public key + let public_key = + include_str!("./test_keys/rsa_pkcs8_unencrypted.pub").replace("testkey", ""); + let public_key = public_key.trim(); + let result = import_key(private_key.to_string(), "".to_string()).unwrap(); + assert_eq!(result.status, SshKeyImportStatus::Success); + assert_eq!(result.ssh_key.unwrap().public_key, public_key); + } + + #[test] + fn import_key_rsa_pkcs8_encrypted() { + let private_key = include_str!("./test_keys/rsa_pkcs8_encrypted"); + let public_key = include_str!("./test_keys/rsa_pkcs8_encrypted.pub").replace("testkey", ""); + let public_key = public_key.trim(); + let result = import_key(private_key.to_string(), "password".to_string()).unwrap(); + assert_eq!(result.status, SshKeyImportStatus::Success); + assert_eq!(result.ssh_key.unwrap().public_key, public_key); + } + + #[test] + fn import_key_ed25519_openssh_encrypted_wrong_password() { + let private_key = include_str!("./test_keys/ed25519_openssh_encrypted"); + let result = import_key(private_key.to_string(), "wrongpassword".to_string()).unwrap(); + assert_eq!(result.status, SshKeyImportStatus::WrongPassword); + } + + #[test] + fn import_non_key_error() { + let result = import_key("not a key".to_string(), "".to_string()).unwrap(); + assert_eq!(result.status, SshKeyImportStatus::ParsingError); + } + + #[test] + fn import_ecdsa_error() { + let private_key = include_str!("./test_keys/ecdsa_openssh_unencrypted"); + let result = import_key(private_key.to_string(), "".to_string()).unwrap(); + assert_eq!(result.status, SshKeyImportStatus::UnsupportedKeyType); + } +} diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs new file mode 100644 index 00000000000..ad0ac837afc --- /dev/null +++ b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs @@ -0,0 +1,118 @@ +use std::sync::Arc; + +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; + +use bitwarden_russh::ssh_agent::{self, Key}; + +#[cfg_attr(target_os = "windows", path = "windows.rs")] +#[cfg_attr(target_os = "macos", path = "unix.rs")] +#[cfg_attr(target_os = "linux", path = "unix.rs")] +mod platform_ssh_agent; + +pub mod generator; +pub mod importer; + +#[derive(Clone)] +pub struct BitwardenDesktopAgent { + keystore: ssh_agent::KeyStore, + cancellation_token: CancellationToken, + show_ui_request_tx: tokio::sync::mpsc::Sender<(u32, String)>, + get_ui_response_rx: Arc>>, + request_id: Arc>, +} + +impl BitwardenDesktopAgent { + async fn get_request_id(&self) -> u32 { + let mut request_id = self.request_id.lock().await; + *request_id += 1; + *request_id + } +} + +impl ssh_agent::Agent for BitwardenDesktopAgent { + async fn confirm(&self, ssh_key: Key) -> bool { + let request_id = self.get_request_id().await; + + let mut rx_channel = self.get_ui_response_rx.lock().await.resubscribe(); + self.show_ui_request_tx + .send((request_id, ssh_key.cipher_uuid.clone())) + .await + .expect("Should send request to ui"); + while let Ok((id, response)) = rx_channel.recv().await { + if id == request_id { + return response; + } + } + false + } +} + +impl BitwardenDesktopAgent { + pub fn stop(&self) { + self.cancellation_token.cancel(); + self.keystore + .0 + .write() + .expect("RwLock is not poisoned") + .clear(); + } + + pub fn set_keys( + &mut self, + new_keys: Vec<(String, String, String)>, + ) -> Result<(), anyhow::Error> { + let keystore = &mut self.keystore; + keystore.0.write().expect("RwLock is not poisoned").clear(); + + for (key, name, cipher_id) in new_keys.iter() { + match parse_key_safe(&key) { + Ok(private_key) => { + let public_key_bytes = private_key + .public_key() + .to_bytes() + .expect("Cipher private key is always correctly parsed"); + keystore.0.write().expect("RwLock is not poisoned").insert( + public_key_bytes, + Key { + private_key: Some(private_key), + name: name.clone(), + cipher_uuid: cipher_id.clone(), + }, + ); + } + Err(e) => { + eprintln!("[SSH Agent Native Module] Error while parsing key: {}", e); + } + } + } + + Ok(()) + } + + pub fn lock(&mut self) -> Result<(), anyhow::Error> { + let keystore = &mut self.keystore; + keystore + .0 + .write() + .expect("RwLock is not poisoned") + .iter_mut() + .for_each(|(_public_key, key)| { + key.private_key = None; + }); + Ok(()) + } +} + +fn parse_key_safe(pem: &str) -> Result { + match ssh_key::private::PrivateKey::from_openssh(pem) { + Ok(key) => match key.public_key().to_bytes() { + Ok(_) => Ok(key), + Err(e) => Err(anyhow::Error::msg(format!( + "Failed to parse public key: {}", + e + ))), + }, + Err(e) => Err(anyhow::Error::msg(format!("Failed to parse key: {}", e))), + } +} diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/named_pipe_listener_stream.rs b/apps/desktop/desktop_native/core/src/ssh_agent/named_pipe_listener_stream.rs new file mode 100644 index 00000000000..69399ae7530 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/ssh_agent/named_pipe_listener_stream.rs @@ -0,0 +1,60 @@ +use std::{ + io, + pin::Pin, + task::{Context, Poll}, +}; + +use futures::Stream; +use tokio::{ + net::windows::named_pipe::{NamedPipeServer, ServerOptions}, + select, +}; +use tokio_util::sync::CancellationToken; + +const PIPE_NAME: &str = r"\\.\pipe\openssh-ssh-agent"; + +#[pin_project::pin_project] +pub struct NamedPipeServerStream { + rx: tokio::sync::mpsc::Receiver, +} + +impl NamedPipeServerStream { + pub fn new(cancellation_token: CancellationToken) -> Self { + let (tx, rx) = tokio::sync::mpsc::channel(16); + tokio::spawn(async move { + println!( + "[SSH Agent Native Module] Creating named pipe server on {}", + PIPE_NAME + ); + let mut listener = ServerOptions::new().create(PIPE_NAME).unwrap(); + loop { + println!("[SSH Agent Native Module] Waiting for connection"); + select! { + _ = cancellation_token.cancelled() => { + println!("[SSH Agent Native Module] Cancellation token triggered, stopping named pipe server"); + break; + } + _ = listener.connect() => { + println!("[SSH Agent Native Module] Incoming connection"); + tx.send(listener).await.unwrap(); + listener = ServerOptions::new().create(PIPE_NAME).unwrap(); + } + } + } + }); + Self { rx } + } +} + +impl Stream for NamedPipeServerStream { + type Item = io::Result; + + fn poll_next( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll>> { + let this = self.project(); + + this.rx.poll_recv(cx).map(|v| v.map(Ok)) + } +} diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/ecdsa_openssh_unencrypted b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/ecdsa_openssh_unencrypted new file mode 100644 index 00000000000..9cf518f8af7 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/ecdsa_openssh_unencrypted @@ -0,0 +1,8 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS +1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQRQzzQ8nQEouF1FMSHkPx1nejNCzF7g +Yb8MHXLdBFM0uJkWs0vzgLJkttts2eDv3SHJqIH6qHpkLtEvgMXE5WcaAAAAoOO1BebjtQ +XmAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFDPNDydASi4XUUx +IeQ/HWd6M0LMXuBhvwwdct0EUzS4mRazS/OAsmS222zZ4O/dIcmogfqoemQu0S+AxcTlZx +oAAAAhAKnIXk6H0Hs3HblklaZ6UmEjjdE/0t7EdYixpMmtpJ4eAAAAB3Rlc3RrZXk= +-----END OPENSSH PRIVATE KEY----- diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/ecdsa_openssh_unencrypted.pub b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/ecdsa_openssh_unencrypted.pub new file mode 100644 index 00000000000..75e08b88b2f --- /dev/null +++ b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/ecdsa_openssh_unencrypted.pub @@ -0,0 +1 @@ +ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFDPNDydASi4XUUxIeQ/HWd6M0LMXuBhvwwdct0EUzS4mRazS/OAsmS222zZ4O/dIcmogfqoemQu0S+AxcTlZxo= testkey diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/ed25519_openssh_encrypted b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/ed25519_openssh_encrypted new file mode 100644 index 00000000000..d3244a3d945 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/ed25519_openssh_encrypted @@ -0,0 +1,8 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABAUTNb0if +fqsoqtfv70CfukAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIHGs3Uw3eyqnFjBI +2eb7Qto4KVc34ZdnBac59Bab54BLAAAAkPA6aovfxQbP6FoOfaRH6u22CxqiUM0bbMpuFf +WETn9FLaBE6LjoHH0ZI5rzNjJaQUNfx0cRcqsIrexw8YINrdVjySmEqrl5hw8gpgy0gGP5 +1Y6vKWdHdrxJCA9YMFOfDs0UhPfpLKZCwm2Sg+Bd8arlI8Gy7y4Jj/60v2bZOLhD2IZQnK +NdJ8xATiIINuTy4g== +-----END OPENSSH PRIVATE KEY----- diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/ed25519_openssh_encrypted.pub b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/ed25519_openssh_encrypted.pub new file mode 100644 index 00000000000..1188fa43f1e --- /dev/null +++ b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/ed25519_openssh_encrypted.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHGs3Uw3eyqnFjBI2eb7Qto4KVc34ZdnBac59Bab54BL testkey diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/ed25519_openssh_unencrypted b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/ed25519_openssh_unencrypted new file mode 100644 index 00000000000..08184f3184e --- /dev/null +++ b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/ed25519_openssh_unencrypted @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACAyQo22TXXNqvF+L8jUSSNeu8UqrsDjvf9pwIwDC9ML6gAAAJDSHpL60h6S ++gAAAAtzc2gtZWQyNTUxOQAAACAyQo22TXXNqvF+L8jUSSNeu8UqrsDjvf9pwIwDC9ML6g +AAAECLdlFLIJbEiFo/f0ROdXMNZAPHGPNhvbbftaPsUZEjaDJCjbZNdc2q8X4vyNRJI167 +xSquwOO9/2nAjAML0wvqAAAAB3Rlc3RrZXkBAgMEBQY= +-----END OPENSSH PRIVATE KEY----- diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/ed25519_openssh_unencrypted.pub b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/ed25519_openssh_unencrypted.pub new file mode 100644 index 00000000000..5c398822022 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/ed25519_openssh_unencrypted.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDJCjbZNdc2q8X4vyNRJI167xSquwOO9/2nAjAML0wvq testkey diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/ed25519_pkcs8_unencrypted b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/ed25519_pkcs8_unencrypted new file mode 100644 index 00000000000..09eb728601e --- /dev/null +++ b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/ed25519_pkcs8_unencrypted @@ -0,0 +1,4 @@ +-----BEGIN PRIVATE KEY----- +MFECAQEwBQYDK2VwBCIEIDY6/OAdDr3PbDss9NsLXK4CxiKUvz5/R9uvjtIzj4Sz +gSEAxsxm1xpZ/4lKIRYm0JrJ5gRZUh7H24/YT/0qGVGzPa0= +-----END PRIVATE KEY----- diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/ed25519_pkcs8_unencrypted.pub b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/ed25519_pkcs8_unencrypted.pub new file mode 100644 index 00000000000..40997e18c89 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/ed25519_pkcs8_unencrypted.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMbMZtcaWf+JSiEWJtCayeYEWVIex9uP2E/9KhlRsz2t diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/rsa_openssh_encrypted b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/rsa_openssh_encrypted new file mode 100644 index 00000000000..bb7bbd85cf9 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/rsa_openssh_encrypted @@ -0,0 +1,39 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABApatKZWf +0kXnaSVhty/RaKAAAAGAAAAAEAAAGXAAAAB3NzaC1yc2EAAAADAQABAAABgQC/v18xGP3q +zRV9iWqyiuwHZ4GpC4K2NO2/i2Yv5A3/bnal7CmiMh/S78lphgxcWtFkwrwlb321FmdHBv +6KOW+EzSiPvmsdkkbpfBXB3Qf2SlhZOZZ7lYeu8KAxL3exvvn8O1GGlUjXGUrFgmC60tHW +DBc1Ncmo8a2dwDLmA/sbLa8su2dvYEFmRg1vaytLDpkn8GS7zAxrUl/g0W2RwkPsByduUz +iQuX90v9WAy7MqOlwBRq6t5o8wdDBVODe0VIXC7N1OS42YUsKF+N0XOnLiJrIIKkXpahMD +pKZHeHQAdUQzsJVhKoLJR8DNDTYyhnJoQG7Q6m2gDTca9oAWvsBiNoEwCvwrt7cDNCz/Gs +lH9HXQgfWcVXn8+fuZgvjO3CxUI16Ev33m0jWoOKJcgK/ZLRnk8SEvsJ8NO32MeR/qUb7I +N/yUcDmPMI/3ecQsakF2cwNzHkyiGVo//yVTpf+vk8b89L+GXbYU5rtswtc2ZEGsQnUkao +NqS8mHqhWQBUkAAAWArmugDAR1KlxY8c/esWbgQ4oP/pAQApehDcFYOrS9Zo78Os4ofEd1 +HkgM7VG1IJafCnn+q+2VXD645zCsx5UM5Y7TcjYDp7reM19Z9JCidSVilleRedTj6LTZx1 +SvetIrTfr81SP6ZGZxNiM0AfIZJO5vk+NliDdbUibvAuLp3oYbzMS3syuRkJePWu+KSxym +nm2+88Wku94p6SIfGRT3nQsMfLS9x6fGQP5Z71DM91V33WCVhrBnvHgNxuAzHDZNfzbPu9 +f2ZD1JGh8azDPe0XRD2jZTyd3Nt+uFMcwnMdigTXaTHExEFkTdQBea1YoprIG56iNZTSoU +/RwE4A0gdrSgJnh+6p8w05u+ia0N2WSL5ZT9QydPhwB8pGHuGBYoXFcAcFwCnIAExPtIUh +wLx1NfC/B2MuD3Uwbx96q5a7xMTH51v0eQDdY3mQzdq/8OHHn9vzmEfV6mxmuyoa0Vh+WG +l2WLB2vD5w0JwRAFx6a3m/rD7iQLDvK3UiYJ7DVz5G3/1w2m4QbXIPCfI3XHU12Pye2a0m +/+/wkS4/BchqB0T4PJm6xfEynXwkEolndf+EvuLSf53XSJ2tfeFPGmmCyPoy9JxCce7wVk +FB/SJw6LXSGUO0QA6vzxbzLEMNrqrpcCiUvDGTA6jds0HnSl8hhgMuZOtQDbFoovIHX0kl +I5pD5pqaUNvQ3+RDFV3qdZyDntaPwCNJumfqUy46GAhYVN2O4p0HxDTs4/c2rkv+fGnG/P +8wc7ACz3QNdjb7XMrW3/vNuwrh/sIjNYM2aiVWtRNPU8bbSmc1sYtpJZ5CsWK1TNrDrY6R +OV89NjBoEC5OXb1c75VdN/jSssvn72XIHjkkDEPboDfmPe889VHfsVoBm18uvWPB4lffdm +4yXAr+Cx16HeiINjcy6iKym2p4ED5IGaSXlmw/6fFgyh2iF7kZTnHawVPTqJNBVMaBRvHn +ylMBLhhEkrXqW43P4uD6l0gWCAPBczcSjHv3Yo28ExtI0QKNk/Uwd2q2kxFRWCtqUyQkrF +KG9IK+ixqstMo+xEb+jcCxCswpJitEIrDOXd51sd7PjCGZtAQ6ycpOuFfCIhwxlBUZdf2O +kM/oKqN/MKMDk+H/OVl8XrLalBOXYDllW+NsL8W6F8DMcdurpQ8lCJHHWBgOdNd62STdvZ +LBf7v8OIrC6F0bVGushsxb7cwGiUrjqUfWjhZoKx35V0dWBcGx7GvzARkvSUM22q14lc7+ +XTP0qC8tcRQfRbnBPJdmnbPDrJeJcDv2ZdbAPdzf2C7cLuuP3mNwLCrLUc7gcF/xgH+Xtd +6KOvzt2UuWv5+cqWOsNspG+lCY0P11BPhlMvmZKO8RGVGg7PKAatG4mSH4IgO4DN2t7U9B +j+v2jq2z5O8O4yJ8T2kWnBlhWzlBoL+R6aaat421f0v+tW/kEAouBQob5I0u1VLB2FkpZE +6tOCK47iuarhf/86NtlPfCM9PdWJQOKcYQ8DCQhp5Lvgd0Vj3WzY+BISDdB2omGRhLUly/ +i40YPASAVnWvgqpCQ4E3rs4DWI/kEcvQH8zVq2YoRa6fVrVf1w/GLFC7m/wkxw8fDfZgMS +Mu+ygbFa9H3aOSZMpTXhdssbOhU70fZOe6GWY9kLBNV4trQeb/pRdbEbMtEmN5TLESgwLA +43dVdHjvpZS677FN/d9+q+pr0Xnuc2VdlXkUyOyv1lFPJIN/XIotiDTnZ3epQQ1zQ3mx32 +8Op2EVgFWpwNmGXJ1zCCA6loUG7e4W/iXkKQxTvOM0fmE4a1Y387GDwJ+pZevYOIOYTkTa +l5jM/6Wm3pLNyE8Ynw3OX0T/p9TO1i3DlXXE/LzcWJFFXAQMo+kc+GlXqjP7K7c6xjQ6vx +2MmKBw== +-----END OPENSSH PRIVATE KEY----- diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/rsa_openssh_encrypted.pub b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/rsa_openssh_encrypted.pub new file mode 100644 index 00000000000..d37f573b686 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/rsa_openssh_encrypted.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC/v18xGP3qzRV9iWqyiuwHZ4GpC4K2NO2/i2Yv5A3/bnal7CmiMh/S78lphgxcWtFkwrwlb321FmdHBv6KOW+EzSiPvmsdkkbpfBXB3Qf2SlhZOZZ7lYeu8KAxL3exvvn8O1GGlUjXGUrFgmC60tHWDBc1Ncmo8a2dwDLmA/sbLa8su2dvYEFmRg1vaytLDpkn8GS7zAxrUl/g0W2RwkPsByduUziQuX90v9WAy7MqOlwBRq6t5o8wdDBVODe0VIXC7N1OS42YUsKF+N0XOnLiJrIIKkXpahMDpKZHeHQAdUQzsJVhKoLJR8DNDTYyhnJoQG7Q6m2gDTca9oAWvsBiNoEwCvwrt7cDNCz/GslH9HXQgfWcVXn8+fuZgvjO3CxUI16Ev33m0jWoOKJcgK/ZLRnk8SEvsJ8NO32MeR/qUb7IN/yUcDmPMI/3ecQsakF2cwNzHkyiGVo//yVTpf+vk8b89L+GXbYU5rtswtc2ZEGsQnUkaoNqS8mHqhWQBUk= testkey diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/rsa_openssh_unencrypted b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/rsa_openssh_unencrypted new file mode 100644 index 00000000000..0d2692e14a2 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/rsa_openssh_unencrypted @@ -0,0 +1,38 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn +NhAAAAAwEAAQAAAYEAtVIe0gnPtD6299/roT7ntZgVe+qIqIMIruJdI2xTanLGhNpBOlzg +WqokbQK+aXATcaB7iQL1SPxIWV2M4jEBQbZuimIgDQvKbJ4TZPKEe1VdsrfuIo+9pDK7cG +Kc+JiWhKjqeTRMj91/qR1fW5IWOUyE1rkwhTNkwJqtYKZLVmd4TXtQsYMMC+I0cz4krfk1 +Yqmaae/gj12h8BvE3Y+Koof4JoLsqPufH+H/bVEayv63RyAQ1/tUv9l+rwJ+svWV4X3zf3 +z40hGF43L/NGl90Vutbn7b9G/RgEdiXyLZciP3XbWbLUM+r7mG9KNuSeoixe5jok15UKqC +XXxVb5IEZ73kaubSfz9JtsqtKG/OjOq6Fbl3Ky7kjvJyGpIvesuSInlpzPXqbLUCLJJfOA +PUZ1wi8uuuRNePzQBMMhq8UtAbB2Dy16d+HlgghzQ00NxtbQMfDZBdApfxm3shIxkUcHzb +DSvriHVaGGoOkmHPAmsdMsMiekuUMe9ljdOhmdTxAAAFgF8XjBxfF4wcAAAAB3NzaC1yc2 +EAAAGBALVSHtIJz7Q+tvff66E+57WYFXvqiKiDCK7iXSNsU2pyxoTaQTpc4FqqJG0Cvmlw +E3Gge4kC9Uj8SFldjOIxAUG2bopiIA0LymyeE2TyhHtVXbK37iKPvaQyu3BinPiYloSo6n +k0TI/df6kdX1uSFjlMhNa5MIUzZMCarWCmS1ZneE17ULGDDAviNHM+JK35NWKpmmnv4I9d +ofAbxN2PiqKH+CaC7Kj7nx/h/21RGsr+t0cgENf7VL/Zfq8CfrL1leF98398+NIRheNy/z +RpfdFbrW5+2/Rv0YBHYl8i2XIj9121my1DPq+5hvSjbknqIsXuY6JNeVCqgl18VW+SBGe9 +5Grm0n8/SbbKrShvzozquhW5dysu5I7ychqSL3rLkiJ5acz16my1AiySXzgD1GdcIvLrrk +TXj80ATDIavFLQGwdg8tenfh5YIIc0NNDcbW0DHw2QXQKX8Zt7ISMZFHB82w0r64h1Whhq +DpJhzwJrHTLDInpLlDHvZY3ToZnU8QAAAAMBAAEAAAGAEL3wpRWtVTf+NnR5QgX4KJsOjs +bI0ABrVpSFo43uxNMss9sgLzagq5ZurxcUBFHKJdF63puEkPTkbEX4SnFaa5of6kylp3a5 +fd55rXY8F9Q5xtT3Wr8ZdFYP2xBr7INQUJb1MXRMBnOeBDw3UBH01d0UHexzB7WHXcZacG +Ria+u5XrQebwmJ3PYJwENSaTLrxDyjSplQy4QKfgxeWNPWaevylIG9vtue5Xd9WXdl6Szs +ONfD3mFxQZagPSIWl0kYIjS3P2ZpLe8+sakRcfci8RjEUP7U+QxqY5VaQScjyX1cSYeQLz +t+/6Tb167aNtQ8CVW3IzM2EEN1BrSbVxFkxWFLxogAHct06Kn87nPn2+PWGWOVCBp9KheO +FszWAJ0Kzjmaga2BpOJcrwjSpGopAb1YPIoRPVepVZlQ4gGwy5gXCFwykT9WTBoJfg0BMQ +r3MSNcoc97eBomIWEa34K0FuQ3rVjMv9ylfyLvDBbRqTJ5zebeOuU+yCQHZUKk8klRAAAA +wAsToNZvYWRsOMTWQom0EW1IHzoL8Cyua+uh72zZi/7enm4yHPJiu2KNgQXfB0GEEjHjbo +9peCW3gZGTV+Ee+cAqwYLlt0SMl/VJNxN3rEG7BAqPZb42Ii2XGjaxzFq0cliUGAdo6UEd +swU8d2I7m9vIZm4nDXzsWOBWgonTKBNyL0DQ6KNOGEyj8W0BTCm7Rzwy7EKzFWbIxr4lSc +vDrJ3t6kOd7jZTF58kRMT0nxR0bf43YzF/3/qSvLYhQm/OOAAAAMEA2F6Yp8SrpQDNDFxh +gi4GeywArrQO9r3EHjnBZi/bacxllSzCGXAvp7m9OKC1VD2wQP2JL1VEIZRUTuGGT6itrm +QpX8OgoxlEJrlC5W0kHumZ3MFGd33W11u37gOilmd6+VfVXBziNG2rFohweAgs8X+Sg5AA +nIfMV6ySXUlvLzMHpGeKRRnnQq9Cwn4rDkVQENLd1i4e2nWFhaPTUwVMR8YuOT766bywr3 +7vG1PQLF7hnf2c/oPHAru+XD9gJWs5AAAAwQDWiB2G23F4Tvq8FiK2mMusSjQzHupl83rm +o3BSNRCvCjaLx6bWhDPSA1edNEF7VuP6rSp+i+UfSORHwOnlgnrvtcJeoDuA72hUeYuqD/ +1C9gghdhKzGTVf/IGTX1tH3rn2Gq9TEyrJs/ITcoOyZprz7VbaD3bP/NEER+m1EHi2TS/3 +SXQEtRm+IIBwba+QLUcsrWdQyIO+1OCXywDrAw50s7tjgr/goHgXTcrSXaKcIEOlPgBZH3 +YPuVuEtRYgX3kAAAAHdGVzdGtleQECAwQ= +-----END OPENSSH PRIVATE KEY----- diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/rsa_openssh_unencrypted.pub b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/rsa_openssh_unencrypted.pub new file mode 100644 index 00000000000..9ec8fec5c58 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/rsa_openssh_unencrypted.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC1Uh7SCc+0Prb33+uhPue1mBV76oiogwiu4l0jbFNqcsaE2kE6XOBaqiRtAr5pcBNxoHuJAvVI/EhZXYziMQFBtm6KYiANC8psnhNk8oR7VV2yt+4ij72kMrtwYpz4mJaEqOp5NEyP3X+pHV9bkhY5TITWuTCFM2TAmq1gpktWZ3hNe1CxgwwL4jRzPiSt+TViqZpp7+CPXaHwG8Tdj4qih/gmguyo+58f4f9tURrK/rdHIBDX+1S/2X6vAn6y9ZXhffN/fPjSEYXjcv80aX3RW61uftv0b9GAR2JfItlyI/ddtZstQz6vuYb0o25J6iLF7mOiTXlQqoJdfFVvkgRnveRq5tJ/P0m2yq0ob86M6roVuXcrLuSO8nIaki96y5IieWnM9epstQIskl84A9RnXCLy665E14/NAEwyGrxS0BsHYPLXp34eWCCHNDTQ3G1tAx8NkF0Cl/GbeyEjGRRwfNsNK+uIdVoYag6SYc8Cax0ywyJ6S5Qx72WN06GZ1PE= testkey diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/rsa_pkcs8_encrypted b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/rsa_pkcs8_encrypted new file mode 100644 index 00000000000..e84d1f07a31 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/rsa_pkcs8_encrypted @@ -0,0 +1,42 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIIHdTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQXquAya5XFx11QEPm +KCSnlwICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEAQIEEKVtEIkI2ELppfUQ +IwfNzowEggcQtWhXVz3LunYTSRVgnexcHEaGkUF6l6a0mGaLSczl+jdCwbbBxibU +EvN7+WMQ44shOk3LyThg0Irl22/7FuovmYc3TSeoMQH4mTROKF+9793v0UMAIAYd +ZhTsexTGncCOt//bq6Fl+L+qPNEkY/OjS+wI9MbOn/Agbcr8/IFSOxuSixxoTKgq +4QR5Ra3USCLyfm+3BoGPMk3tbEjrwjvzx/eTaWzt6hdc0yX4ehtqExF8WAYB43DW +3Y1slA1T464/f1j4KXhoEXDTBOuvNvnbr7lhap8LERIGYGnQKv2m2Kw57Wultnoe +joEQ+vTl5n92HI77H8tbgSbTYuEQ2n9pDD7AAzYGBn15c4dYEEGJYdHnqfkEF+6F +EgPa+Xhj2qqk5nd1bzPSv6iX7XfAX2sRzfZfoaFETmR0ZKbs0aMsndC5wVvd3LpA +m86VUihQxDvU8F4gizrNYj4NaNRv4lrxBj7Kb6BO/qT3DB8Uqu43oyrvA90iMigi +EvuCViwwhwCpe+AxCqLGrzvIpiZCksTOtSPEvnMehw2WA3yd/n88Nis5zD4b65+q +Tx9Q0Qm1LIi1Bq+s60+W1HK3KfaLrJaoX3JARZoWfxurZwtj+cMlo5zK1Ha2HHqQ +kVn21tOcQU/Yljt3Db+CKZ5Tos/rPywxGnkeMABzJgyajPHkYaSgWZrOEueihfS1 +5eDtEMBehEyHfcUrL7XGnn4lOzwQHZIEFnVdV0YGaQY8Wz212IjeWxV09gM2OEP6 +PEDI3GSsqOnGkPrnson5tsIUcvpk9smy9AA9qVhNowzeWCWmsF8K9fn/O94tIzyN +2EK0tkf8oDVROlbEh/jDa2aAHqPGCXBEqq1CbZXQpNk4FlRzkjtxdzPNiXLf45xO +IjOTTzgaVYWiKZD9ymNjNPIaDCPB6c4LtUm86xUQzXdztBm1AOI3PrNI6nIHxWbF +bPeEkJMRiN7C9j5nQMgQRB67CeLhzvqUdyfrYhzc7HY479sKDt9Qn8R0wpFw0QSA +G1gpGyxFaBFSdIsil5K4IZYXxh7qTlOKzaqArTI0Dnuk8Y67z8zaxN5BkvOfBd+Q +SoDz6dzn7KIJrK4XP3IoNfs6EVT/tlMPRY3Y/Ug+5YYjRE497cMxW8jdf3ZwgWHQ +JubPH+0IpwNNZOOf4JXALULsDj0N7rJ1iZAY67b+7YMin3Pz0AGQhQdEdqnhaxPh +oMvL9xFewkyujwCmPj1oQi1Uj2tc1i4ZpxY0XmYn/FQiQH9/XLdIlOMSTwGx86bw +90e9VJHfCmflLOpENvv5xr2isNbn0aXNAOQ4drWJaYLselW2Y4N1iqBCWJKFyDGw +4DevhhamEvsrdoKgvnuzfvA44kQGmfTjCuMu7IR5zkxevONNrynKcHkoWATzgxSS +leXCxzc9VA0W7XUSMypHGPNHJCwYZvSWGx0qGI3VREUk2J7OeVjXCFNeHFc2Le3P +dAm+DqRiyPBVX+yW+i7rjZLyypLzmYo9CyhlohOxTeGa6iTxBUZfYGoc0eJNqfgN +/5hkoPFYGkcd/p41SKSg7akrJPRc+uftH0oVI0wVorGSVOvwXRn7QM+wFKlv3DQD +ysMP7cOKqMyhJsqeW74/iWEmhbFIDKexSd/KTQ6PirVlzj7148Fl++yxaZpnZ6MY +iyzifvLcT701GaewIwi9YR9f1BWUXYHTjK3sB3lLPyMbA4w9bRkylcKrbGf85q0E +LXPlfh+1C9JctczDCqr2iLRoc/5j23GeN8RWfUNpZuxjFv9sxkV4iG+UapIuOBIc +Os4//3w24XcTXYqBdX2Y7+238xq6/94+4hIhXAcMFc2Nr3CEAZCuKYChVL9CSA3v +4sZM4rbOz6kWTC2G3SAtkLSk7hCJ6HLXzrnDb4++g3JYJWLeaQ+4ZaxWuKymnehN +xumXCwCn0stmCjXYV/yM3TeVnMfBTIB13KAjbn0czGW00nj79rNJJzkOlp9tIPen +pUPRFPWjgLF+hVQrwqJ3HPmt6Rt6mKzZ4FEpBXMDjvlKabnFvBdl3gbNHSfxhGHi +FzG3phg1CiXaURQUAf21PV+djfBha7kDwMXnpgZ+PIyGDxRj61StV/NSlhg+8GrL +ccoDOkfpy2zn++rmAqA21rTEChFN5djdsJw45GqPKUPOAgxKBsvqpoMIqq/C2pHP +iMiBriZULV9l0tHn5MMcNQbYAmp4BsTo6maHByAVm1/7/VPQn6EieuGroYgSk2H7 +pnwM01IUfGGP3NKlq9EiiF1gz8acZ5v8+jkZM2pIzh8Trw0mtwBpnyiyXmpbR/RG +m/TTU/gNQ/94ZaNJ/shPoBwikWXvOm+0Z0ZAwu3xefTyENGhjmb5GXshEN/5WwCm +NNrtUPlkGkYJrnSCVM/lHtjShwbLw2w/1sag1uDuXwirxxYh9r7D6HQ= +-----END ENCRYPTED PRIVATE KEY----- diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/rsa_pkcs8_encrypted.pub b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/rsa_pkcs8_encrypted.pub new file mode 100644 index 00000000000..f3c1b15f0a3 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/rsa_pkcs8_encrypted.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCcHkc0xfH4w9aW41S9M/BfancSY4QPc2O4G1cRjFfK8QrLEGDA7NiHtoEML0afcurRXD3NVxuKaAns0w6EoS4CjzXUqVHTLA4SUyuapr8k0Eu2xOpbCwC3jDovhckoKloq7BvE6rC2i5wjSMadtIJKt/dqWI3HLjUMz1BxQJAU/qAbicj1SFZSjA/MubVBzcq93XOvByMtlIFu7wami3FTc37rVkGeUFHtK8ZbvG3n1aaTF79bBgSPuoq5BfcMdGr4WfQyGQzgse4v4hQ8yKYrtE0jo0kf06hEORimwOIU/W5IH1r+/xFs7qGKcPnFSZRIFv5LfMPTo8b+OsBRflosyfUumDEX97GZE7DSQl0EJzNvWeKwl7dQ8RUJTkbph2CjrxY77DFim+165Uj/WRr4uq2qMNhA2xNSD19+TA6AHdpGw4WZd37q2/n+EddlaJEH8MzpgtHNG9MiYh5ScZ+AG0QugflozJcQNc7n8N9Lpu1sRoejV5RhurHg/TYwVK8= testkey diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/rsa_pkcs8_unencrypted b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/rsa_pkcs8_unencrypted new file mode 100644 index 00000000000..0bfe2bc5067 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/rsa_pkcs8_unencrypted @@ -0,0 +1,40 @@ +-----BEGIN PRIVATE KEY----- +MIIG/QIBADANBgkqhkiG9w0BAQEFAASCBucwggbjAgEAAoIBgQCn4+QiJojZ9mgc +9KYJIvDWGaz4qFhf0CButg6L8zEoHKwuiN+mqcEciCCOa9BNiJmm8NTTehZvrrgl +GG59zIbqYtDAHjVn+vtb49xPzIv+M651Yqj08lIbR9tEIHKCq7aH8GlDm8NgG9Ez +JGjlL7okQym4TH1MHl+s4mUyr/qb2unlZBDixAQsphU8iCLftukWCIkmQg4CSj1G +h3WbBlZ+EX5eW0EXuAw4XsSbBTWV9CHRowVIpYqPvEYSpHsoCjEcd988p19hpiGk +nA0J4z7JfUlNgyT/1chb8GCTDT+2DCBRApbsIg6TOBVS+PR6emAQ3eZzUW0+3/oR +M4ip0ujltQy8uU6gvYIAqx5wXGMThVpZcUgahKiSsVo/s4b84iMe4DG3W8jz4qi6 +yyNv0VedEzPUZ1lXd1GJFoy9uKNuSTe+1ksicAcluZN6LuNsPHcPxFCzOcmoNnVX +EKAXInt+ys//5CDVasroZSAHZnDjUD4oNsLI3VIOnGxgXrkwSH0CAwEAAQKCAYAA +2SDMf7OBHw1OGM9OQa1ZS4u+ktfQHhn31+FxbrhWGp+lDt8gYABVf6Y4dKN6rMtn +7D9gVSAlZCAn3Hx8aWAvcXHaspxe9YXiZDTh+Kd8EIXxBQn+TiDA5LH0dryABqmM +p20vYKtR7OS3lIIXfFBSrBMwdunKzLwmKwZLWq0SWf6vVbwpxRyR9CyByodF6Djm +ZK3QB2qQ3jqlL1HWXL0VnyArY7HLvUvfLLK4vMPqnsSH+FdHvhcEhwqMlWT44g+f +hqWtCJNnjDgLK3FPbI8Pz9TF8dWJvOmp5Q6iSBua1e9x2LizVuNSqiFc7ZTLeoG4 +nDj7T2BtqB0E1rNUDEN1aBo+UZmHJK7LrzfW/B+ssi2WwIpfxYa1lO6HFod5/YQi +XV1GunyH1chCsbvOFtXvAHASO4HTKlJNbWhRF1GXqnKpAaHDPCVuwp3eq6Yf0oLb +XrL3KFZ3jwWiWbpQXRVvpqzaJwZn3CN1yQgYS9j17a9wrPky+BoJxXjZ/oImWLEC +gcEA0lkLwiHvmTYFTCC7PN938Agk9/NQs5PQ18MRn9OJmyfSpYqf/gNp+Md7xUgt +F/MTif7uelp2J7DYf6fj9EYf9g4EuW+SQgFP4pfiJn1+zGFeTQq1ISvwjsA4E8ZS +t+GIumjZTg6YiL1/A79u4wm24swt7iqnVViOPtPGOM34S1tAamjZzq2eZDmAF6pA +fmuTMdinCMR1E1kNJYbxeqLiqQCXuwBBnHOOOJofN3AkvzjRUBB9udvniqYxH3PQ +cxPxAoHBAMxT5KwBhZhnJedYN87Kkcpl7xdMkpU8b+aXeZoNykCeoC+wgIQexnSW +mFk4HPkCNxvCWlbkOT1MHrTAKFnaOww23Ob+Vi6A9n0rozo9vtoJig114GB0gUqE +mtfLhO1P5AE8yzogE+ILHyp0BqXt8vGIfzpDnCkN+GKl8gOOMPrR4NAcLO+Rshc5 +nLs7BGB4SEi126Y6mSfp85m0++1QhWMz9HzqJEHCWKVcZYdCdEONP9js04EUnK33 +KtlJIWzZTQKBwAT0pBpGwmZRp35Lpx2gBitZhcVxrg0NBnaO2fNyAGPvZD8SLQLH +AdAiov/a23Uc/PDbWLL5Pp9gwzj+s5glrssVOXdE8aUscr1b5rARdNNL1/Tos6u8 +ZUZ3sNqGaZx7a8U4gyYboexWyo9EC1C+AdkGBm7+AkM4euFwC9N6xsa/t5zKK5d6 +76hc0m+8SxivYCBkgkrqlfeGuZCQxU+mVsC0it6U+va8ojUjLGkZ80OuCwBf4xZl +3+acU7vx9o8/gQKBwB7BrhU6MWrsc+cr/1KQaXum9mNyckomi82RFYvb8Yrilcg3 +8FBy9XqNRKeBa9MLw1HZYpHbzsXsVF7u4eQMloDTLVNUC5L6dKAI1owoyTa24uH9 +0WWTg/a8mTZMe1jhgrew+AJq27NV6z4PswR9GenDmyshDDudz7rBsflZCQRoXUfW +RelV7BHU6UPBsXn4ASF4xnRyM6WvcKy9coKZcUqqgm3fLM/9OizCCMJgfXHBrE+x +7nBqst746qlEedSRrQKBwQCVYwwKCHNlZxl0/NMkDJ+hp7/InHF6mz/3VO58iCb1 +9TLDVUC2dDGPXNYwWTT9PclefwV5HNBHcAfTzgB4dpQyNiDyV914HL7DFEGduoPn +wBYjeFre54v0YjjnskjJO7myircdbdX//i+7LMUw5aZZXCC8a5BD/rdV6IKJWJG5 +QBXbe5fVf1XwOjBTzlhIPIqhNFfSu+mFikp5BRwHGBqsKMju6inYmW6YADeY/SvO +QjDEB37RqGZxqyIx8V2ZYwU= +-----END PRIVATE KEY----- diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/rsa_pkcs8_unencrypted.pub b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/rsa_pkcs8_unencrypted.pub new file mode 100644 index 00000000000..a3e04eed461 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/ssh_agent/test_keys/rsa_pkcs8_unencrypted.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCn4+QiJojZ9mgc9KYJIvDWGaz4qFhf0CButg6L8zEoHKwuiN+mqcEciCCOa9BNiJmm8NTTehZvrrglGG59zIbqYtDAHjVn+vtb49xPzIv+M651Yqj08lIbR9tEIHKCq7aH8GlDm8NgG9EzJGjlL7okQym4TH1MHl+s4mUyr/qb2unlZBDixAQsphU8iCLftukWCIkmQg4CSj1Gh3WbBlZ+EX5eW0EXuAw4XsSbBTWV9CHRowVIpYqPvEYSpHsoCjEcd988p19hpiGknA0J4z7JfUlNgyT/1chb8GCTDT+2DCBRApbsIg6TOBVS+PR6emAQ3eZzUW0+3/oRM4ip0ujltQy8uU6gvYIAqx5wXGMThVpZcUgahKiSsVo/s4b84iMe4DG3W8jz4qi6yyNv0VedEzPUZ1lXd1GJFoy9uKNuSTe+1ksicAcluZN6LuNsPHcPxFCzOcmoNnVXEKAXInt+ys//5CDVasroZSAHZnDjUD4oNsLI3VIOnGxgXrkwSH0= testkey diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs b/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs new file mode 100644 index 00000000000..c1a39506660 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs @@ -0,0 +1,77 @@ +use std::{ + collections::HashMap, + sync::{Arc, RwLock}, +}; + +use bitwarden_russh::ssh_agent; +use homedir::my_home; +use tokio::{net::UnixListener, sync::Mutex}; +use tokio_util::sync::CancellationToken; + +use super::BitwardenDesktopAgent; + +impl BitwardenDesktopAgent { + pub async fn start_server( + auth_request_tx: tokio::sync::mpsc::Sender<(u32, String)>, + auth_response_rx: Arc>>, + ) -> Result { + use std::path::PathBuf; + + let agent = BitwardenDesktopAgent { + keystore: ssh_agent::KeyStore(Arc::new(RwLock::new(HashMap::new()))), + cancellation_token: CancellationToken::new(), + show_ui_request_tx: auth_request_tx, + get_ui_response_rx: auth_response_rx, + request_id: Arc::new(tokio::sync::Mutex::new(0)), + }; + let cloned_agent_state = agent.clone(); + tokio::spawn(async move { + let ssh_path = match std::env::var("BITWARDEN_SSH_AUTH_SOCK") { + Ok(path) => path, + Err(_) => { + println!("[SSH Agent Native Module] BITWARDEN_SSH_AUTH_SOCK not set, using default path"); + + let ssh_agent_directory = match my_home() { + Ok(Some(home)) => home, + _ => PathBuf::from("/tmp/"), + }; + ssh_agent_directory + .join(".bitwarden-ssh-agent.sock") + .to_str() + .expect("Path should be valid") + .to_owned() + } + }; + + println!( + "[SSH Agent Native Module] Starting SSH Agent server on {:?}", + ssh_path + ); + let sockname = std::path::Path::new(&ssh_path); + let _ = std::fs::remove_file(sockname); + match UnixListener::bind(sockname) { + Ok(listener) => { + let wrapper = tokio_stream::wrappers::UnixListenerStream::new(listener); + let cloned_keystore = cloned_agent_state.keystore.clone(); + let cloned_cancellation_token = cloned_agent_state.cancellation_token.clone(); + let _ = ssh_agent::serve( + wrapper, + cloned_agent_state, + cloned_keystore, + cloned_cancellation_token, + ) + .await; + println!("[SSH Agent Native Module] SSH Agent server exited"); + } + Err(e) => { + eprintln!( + "[SSH Agent Native Module] Error while starting agent server: {}", + e + ); + } + } + }); + + Ok(agent) + } +} diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/windows.rs b/apps/desktop/desktop_native/core/src/ssh_agent/windows.rs new file mode 100644 index 00000000000..fd6d9dacb9f --- /dev/null +++ b/apps/desktop/desktop_native/core/src/ssh_agent/windows.rs @@ -0,0 +1,41 @@ +use bitwarden_russh::ssh_agent; +pub mod named_pipe_listener_stream; + +use std::{ + collections::HashMap, + sync::{Arc, RwLock}, +}; +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; + +use super::BitwardenDesktopAgent; + +impl BitwardenDesktopAgent { + pub async fn start_server( + auth_request_tx: tokio::sync::mpsc::Sender<(u32, String)>, + auth_response_rx: Arc>>, + ) -> Result { + let agent_state = BitwardenDesktopAgent { + keystore: ssh_agent::KeyStore(Arc::new(RwLock::new(HashMap::new()))), + show_ui_request_tx: auth_request_tx, + get_ui_response_rx: auth_response_rx, + cancellation_token: CancellationToken::new(), + request_id: Arc::new(tokio::sync::Mutex::new(0)), + }; + let stream = named_pipe_listener_stream::NamedPipeServerStream::new( + agent_state.cancellation_token.clone(), + ); + + let cloned_agent_state = agent_state.clone(); + tokio::spawn(async move { + let _ = ssh_agent::serve( + stream, + cloned_agent_state.clone(), + cloned_agent_state.keystore.clone(), + cloned_agent_state.cancellation_token.clone(), + ) + .await; + }); + Ok(agent_state) + } +} diff --git a/apps/desktop/desktop_native/napi/Cargo.toml b/apps/desktop/desktop_native/napi/Cargo.toml index 64ab106e576..bf7701a6566 100644 --- a/apps/desktop/desktop_native/napi/Cargo.toml +++ b/apps/desktop/desktop_native/napi/Cargo.toml @@ -14,12 +14,15 @@ default = [] manual_test = [] [dependencies] +base64 = "=0.22.1" +hex = "=0.4.3" anyhow = "=1.0.93" desktop_core = { path = "../core" } napi = { version = "=2.16.13", features = ["async"] } napi-derive = "=2.16.12" -tokio = { version = "1.38.0" } -tokio-util = "0.7.11" +tokio = { version = "=1.40.0" } +tokio-util = "=0.7.12" +tokio-stream = "=0.1.15" [target.'cfg(windows)'.dependencies] windows-registry = "=0.3.0" diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index 8e1c1381b5f..6d1a7b8abbc 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -42,6 +42,41 @@ export declare namespace clipboards { export function read(): Promise export function write(text: string, password: boolean): Promise } +export declare namespace sshagent { + export interface PrivateKey { + privateKey: string + name: string + cipherId: string + } + export interface SshKey { + privateKey: string + publicKey: string + keyFingerprint: string + } + export const enum SshKeyImportStatus { + /** ssh key was parsed correctly and will be returned in the result */ + Success = 0, + /** ssh key was parsed correctly but is encrypted and requires a password */ + PasswordRequired = 1, + /** ssh key was parsed correctly, and a password was provided when calling the import, but it was incorrect */ + WrongPassword = 2, + /** ssh key could not be parsed, either due to an incorrect / unsupported format (pkcs#8) or key type (ecdsa), or because the input is not an ssh key */ + ParsingError = 3, + /** ssh key type is not supported (e.g. ecdsa) */ + UnsupportedKeyType = 4 + } + export interface SshKeyImportResult { + status: SshKeyImportStatus + sshKey?: SshKey + } + export function serve(callback: (err: Error | null, arg: string) => any): Promise + export function stop(agentState: SshAgentState): void + export function setKeys(agentState: SshAgentState, newKeys: Array): void + export function lock(agentState: SshAgentState): void + export function importKey(encodedKey: string, password: string): SshKeyImportResult + export function generateKeypair(keyAlgorithm: string): Promise + export class SshAgentState { } +} export declare namespace processisolations { export function disableCoredumps(): Promise export function isCoreDumpingDisabled(): Promise diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index face07f2f4e..60a8326a8e5 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -54,12 +54,16 @@ pub mod biometrics { hwnd: napi::bindgen_prelude::Buffer, message: String, ) -> napi::Result { - Biometric::prompt(hwnd.into(), message).await.map_err(|e| napi::Error::from_reason(e.to_string())) + Biometric::prompt(hwnd.into(), message) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) } #[napi] pub async fn available() -> napi::Result { - Biometric::available().await.map_err(|e| napi::Error::from_reason(e.to_string())) + Biometric::available() + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) } #[napi] @@ -151,6 +155,199 @@ pub mod clipboards { } } +#[napi] +pub mod sshagent { + use std::sync::Arc; + + use napi::{ + bindgen_prelude::Promise, + threadsafe_function::{ErrorStrategy::CalleeHandled, ThreadsafeFunction}, + }; + use tokio::{self, sync::Mutex}; + + #[napi] + pub struct SshAgentState { + state: desktop_core::ssh_agent::BitwardenDesktopAgent, + } + + #[napi(object)] + pub struct PrivateKey { + pub private_key: String, + pub name: String, + pub cipher_id: String, + } + + #[napi(object)] + pub struct SshKey { + pub private_key: String, + pub public_key: String, + pub key_fingerprint: String, + } + + impl From for SshKey { + fn from(key: desktop_core::ssh_agent::importer::SshKey) -> Self { + SshKey { + private_key: key.private_key, + public_key: key.public_key, + key_fingerprint: key.key_fingerprint, + } + } + } + + #[napi] + pub enum SshKeyImportStatus { + /// ssh key was parsed correctly and will be returned in the result + Success, + /// ssh key was parsed correctly but is encrypted and requires a password + PasswordRequired, + /// ssh key was parsed correctly, and a password was provided when calling the import, but it was incorrect + WrongPassword, + /// ssh key could not be parsed, either due to an incorrect / unsupported format (pkcs#8) or key type (ecdsa), or because the input is not an ssh key + ParsingError, + /// ssh key type is not supported (e.g. ecdsa) + UnsupportedKeyType, + } + + impl From for SshKeyImportStatus { + fn from(status: desktop_core::ssh_agent::importer::SshKeyImportStatus) -> Self { + match status { + desktop_core::ssh_agent::importer::SshKeyImportStatus::Success => { + SshKeyImportStatus::Success + } + desktop_core::ssh_agent::importer::SshKeyImportStatus::PasswordRequired => { + SshKeyImportStatus::PasswordRequired + } + desktop_core::ssh_agent::importer::SshKeyImportStatus::WrongPassword => { + SshKeyImportStatus::WrongPassword + } + desktop_core::ssh_agent::importer::SshKeyImportStatus::ParsingError => { + SshKeyImportStatus::ParsingError + } + desktop_core::ssh_agent::importer::SshKeyImportStatus::UnsupportedKeyType => { + SshKeyImportStatus::UnsupportedKeyType + } + } + } + } + + #[napi(object)] + pub struct SshKeyImportResult { + pub status: SshKeyImportStatus, + pub ssh_key: Option, + } + + impl From for SshKeyImportResult { + fn from(result: desktop_core::ssh_agent::importer::SshKeyImportResult) -> Self { + SshKeyImportResult { + status: result.status.into(), + ssh_key: result.ssh_key.map(|k| k.into()), + } + } + } + + #[napi] + pub async fn serve( + callback: ThreadsafeFunction, + ) -> napi::Result { + let (auth_request_tx, mut auth_request_rx) = tokio::sync::mpsc::channel::<(u32, String)>(32); + let (auth_response_tx, auth_response_rx) = tokio::sync::broadcast::channel::<(u32, bool)>(32); + let auth_response_tx_arc = Arc::new(Mutex::new(auth_response_tx)); + tokio::spawn(async move { + let _ = auth_response_rx; + + while let Some((request_id, cipher_uuid)) = auth_request_rx.recv().await { + let cloned_request_id = request_id.clone(); + let cloned_cipher_uuid = cipher_uuid.clone(); + let cloned_response_tx_arc = auth_response_tx_arc.clone(); + let cloned_callback = callback.clone(); + tokio::spawn(async move { + let request_id = cloned_request_id; + let cipher_uuid = cloned_cipher_uuid; + let auth_response_tx_arc = cloned_response_tx_arc; + let callback = cloned_callback; + let promise_result: Result, napi::Error> = + callback.call_async(Ok(cipher_uuid)).await; + match promise_result { + Ok(promise_result) => match promise_result.await { + Ok(result) => { + let _ = auth_response_tx_arc.lock().await.send((request_id, result)) + .expect("should be able to send auth response to agent"); + } + Err(e) => { + println!("[SSH Agent Native Module] calling UI callback promise was rejected: {}", e); + let _ = auth_response_tx_arc.lock().await.send((request_id, false)) + .expect("should be able to send auth response to agent"); + } + }, + Err(e) => { + println!("[SSH Agent Native Module] calling UI callback could not create promise: {}", e); + let _ = auth_response_tx_arc.lock().await.send((request_id, false)) + .expect("should be able to send auth response to agent"); + } + } + }); + } + }); + + match desktop_core::ssh_agent::BitwardenDesktopAgent::start_server( + auth_request_tx, + Arc::new(Mutex::new(auth_response_rx)), + ) + .await + { + Ok(state) => Ok(SshAgentState { state }), + Err(e) => Err(napi::Error::from_reason(e.to_string())), + } + } + + #[napi] + pub fn stop(agent_state: &mut SshAgentState) -> napi::Result<()> { + let bitwarden_agent_state = &mut agent_state.state; + bitwarden_agent_state.stop(); + Ok(()) + } + + #[napi] + pub fn set_keys( + agent_state: &mut SshAgentState, + new_keys: Vec, + ) -> napi::Result<()> { + let bitwarden_agent_state = &mut agent_state.state; + bitwarden_agent_state + .set_keys( + new_keys + .iter() + .map(|k| (k.private_key.clone(), k.name.clone(), k.cipher_id.clone())) + .collect(), + ) + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + Ok(()) + } + + #[napi] + pub fn lock(agent_state: &mut SshAgentState) -> napi::Result<()> { + let bitwarden_agent_state = &mut agent_state.state; + bitwarden_agent_state + .lock() + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi] + pub fn import_key(encoded_key: String, password: String) -> napi::Result { + let result = desktop_core::ssh_agent::importer::import_key(encoded_key, password) + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + Ok(result.into()) + } + + #[napi] + pub async fn generate_keypair(key_algorithm: String) -> napi::Result { + desktop_core::ssh_agent::generator::generate_keypair(key_algorithm) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + .map(|k| k.into()) + } +} + #[napi] pub mod processisolations { #[napi] @@ -172,12 +369,19 @@ pub mod processisolations { #[napi] pub mod powermonitors { - use napi::{threadsafe_function::{ErrorStrategy::CalleeHandled, ThreadsafeFunction, ThreadsafeFunctionCallMode}, tokio}; + use napi::{ + threadsafe_function::{ + ErrorStrategy::CalleeHandled, ThreadsafeFunction, ThreadsafeFunctionCallMode, + }, + tokio, + }; #[napi] pub async fn on_lock(callback: ThreadsafeFunction<(), CalleeHandled>) -> napi::Result<()> { let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(32); - desktop_core::powermonitor::on_lock(tx).await.map_err(|e| napi::Error::from_reason(e.to_string()))?; + desktop_core::powermonitor::on_lock(tx) + .await + .map_err(|e| napi::Error::from_reason(e.to_string()))?; tokio::spawn(async move { while let Some(message) = rx.recv().await { callback.call(Ok(message.into()), ThreadsafeFunctionCallMode::NonBlocking); @@ -190,7 +394,6 @@ pub mod powermonitors { pub async fn is_lock_monitor_available() -> napi::Result { Ok(desktop_core::powermonitor::is_lock_monitor_available().await) } - } #[napi] diff --git a/apps/desktop/native-messaging-test-runner/package-lock.json b/apps/desktop/native-messaging-test-runner/package-lock.json index 7e1d7193b58..f57f067907a 100644 --- a/apps/desktop/native-messaging-test-runner/package-lock.json +++ b/apps/desktop/native-messaging-test-runner/package-lock.json @@ -14,7 +14,7 @@ "module-alias": "2.2.3", "node-ipc": "9.2.1", "ts-node": "10.9.2", - "uuid": "11.0.1", + "uuid": "11.0.3", "yargs": "17.7.2" }, "devDependencies": { @@ -421,9 +421,9 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.1.tgz", - "integrity": "sha512-wt9UB5EcLhnboy1UvA1mvGPXkIIrHSu+3FmUksARfdVw9tuPf3CH/CohxO0Su1ApoKAeT6BVzAJIvjTuQVSmuQ==", + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz", + "integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" diff --git a/apps/desktop/native-messaging-test-runner/package.json b/apps/desktop/native-messaging-test-runner/package.json index 0c38902ea4c..ed2c4bb29cf 100644 --- a/apps/desktop/native-messaging-test-runner/package.json +++ b/apps/desktop/native-messaging-test-runner/package.json @@ -19,7 +19,7 @@ "module-alias": "2.2.3", "node-ipc": "9.2.1", "ts-node": "10.9.2", - "uuid": "11.0.1", + "uuid": "11.0.3", "yargs": "17.7.2" }, "devDependencies": { diff --git a/apps/desktop/src/app/accounts/settings.component.html b/apps/desktop/src/app/accounts/settings.component.html index 76cf98b1b24..7336ce09dd8 100644 --- a/apps/desktop/src/app/accounts/settings.component.html +++ b/apps/desktop/src/app/accounts/settings.component.html @@ -419,6 +419,23 @@ "enableHardwareAccelerationDesc" | i18n }} + + + + + {{ "enableSshAgent" | i18n }} + + + {{ + "enableSshAgentDesc" | i18n + }} + diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index 0c5fa187cb6..a8ce45f53c7 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -12,7 +12,9 @@ import { UserVerificationService as UserVerificationServiceAbstraction } from "@ import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { DeviceType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -53,6 +55,7 @@ export class SettingsComponent implements OnInit, OnDestroy { showAlwaysShowDock = false; requireEnableTray = false; showDuckDuckGoIntegrationOption = false; + showSshAgentOption = false; isWindows: boolean; isLinux: boolean; @@ -107,6 +110,7 @@ export class SettingsComponent implements OnInit, OnDestroy { disabled: true, }), enableHardwareAcceleration: true, + enableSshAgent: false, enableDuckDuckGoBrowserIntegration: false, theme: [null as ThemeType | null], locale: [null as string | null], @@ -137,6 +141,7 @@ export class SettingsComponent implements OnInit, OnDestroy { private pinService: PinServiceAbstraction, private logService: LogService, private nativeMessagingManifestService: NativeMessagingManifestService, + private configService: ConfigService, ) { const isMac = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop; @@ -200,6 +205,8 @@ export class SettingsComponent implements OnInit, OnDestroy { if (activeAccount == null || activeAccount.id == null) { return; } + + this.showSshAgentOption = await this.configService.getFeatureFlag(FeatureFlag.SSHAgent); this.userHasMasterPassword = await this.userVerificationService.hasMasterPassword(); this.isWindows = this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop; @@ -272,6 +279,7 @@ export class SettingsComponent implements OnInit, OnDestroy { enableHardwareAcceleration: await firstValueFrom( this.desktopSettingsService.hardwareAcceleration$, ), + enableSshAgent: await firstValueFrom(this.desktopSettingsService.sshAgentEnabled$), theme: await firstValueFrom(this.themeStateService.selectedTheme$), locale: await firstValueFrom(this.i18nService.userSetLocale$), }; @@ -723,6 +731,11 @@ export class SettingsComponent implements OnInit, OnDestroy { ); } + async saveSshAgent() { + this.logService.debug("Saving Ssh Agent settings", this.form.value.enableSshAgent); + await this.desktopSettingsService.setSshAgentEnabled(this.form.value.enableSshAgent); + } + private async generateVaultTimeoutOptions(): Promise { let vaultTimeoutOptions: VaultTimeoutOption[] = [ { name: this.i18nService.t("oneMinute"), value: 1 }, diff --git a/apps/desktop/src/app/app.module.ts b/apps/desktop/src/app/app.module.ts index c4861b96608..d787234e8b3 100644 --- a/apps/desktop/src/app/app.module.ts +++ b/apps/desktop/src/app/app.module.ts @@ -22,6 +22,7 @@ import { SsoComponent } from "../auth/sso.component"; import { TwoFactorOptionsComponent } from "../auth/two-factor-options.component"; import { TwoFactorComponent } from "../auth/two-factor.component"; import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component"; +import { SshAgentService } from "../platform/services/ssh-agent.service"; import { PremiumComponent } from "../vault/app/accounts/premium.component"; import { AddEditCustomFieldsComponent } from "../vault/app/vault/add-edit-custom-fields.component"; import { AddEditComponent } from "../vault/app/vault/add-edit.component"; @@ -100,6 +101,7 @@ import { SendComponent } from "./tools/send/send.component"; ViewComponent, ViewCustomFieldsComponent, ], + providers: [SshAgentService], bootstrap: [AppComponent], }) export class AppModule {} diff --git a/apps/desktop/src/app/services/init.service.ts b/apps/desktop/src/app/services/init.service.ts index bc3f63a7902..627a9b62228 100644 --- a/apps/desktop/src/app/services/init.service.ts +++ b/apps/desktop/src/app/services/init.service.ts @@ -21,6 +21,7 @@ import { UserId } from "@bitwarden/common/types/guid"; import { KeyService as KeyServiceAbstraction } from "@bitwarden/key-management"; import { I18nRendererService } from "../../platform/services/i18n.renderer.service"; +import { SshAgentService } from "../../platform/services/ssh-agent.service"; import { NativeMessagingService } from "../../services/native-messaging.service"; @Injectable() @@ -41,11 +42,13 @@ export class InitService { private encryptService: EncryptService, private userAutoUnlockKeyService: UserAutoUnlockKeyService, private accountService: AccountService, + private sshAgentService: SshAgentService, @Inject(DOCUMENT) private document: Document, ) {} init() { return async () => { + await this.sshAgentService.init(); this.nativeMessagingService.init(); await this.stateService.init({ runMigrations: false }); // Desktop will run them in main process diff --git a/apps/desktop/src/locales/de/messages.json b/apps/desktop/src/locales/de/messages.json index 6e7282b9a81..070da06de04 100644 --- a/apps/desktop/src/locales/de/messages.json +++ b/apps/desktop/src/locales/de/messages.json @@ -1684,10 +1684,10 @@ "message": "Die Kontolöschung ist dauerhaft. Sie kann nicht rückgängig gemacht werden." }, "cannotDeleteAccount": { - "message": "Cannot delete account" + "message": "Konto kann nicht gelöscht werden" }, "cannotDeleteAccountDesc": { - "message": "This action cannot be completed because your account is owned by an organization. Contact your organization administrator for additional details." + "message": "Diese Aktion kann nicht abgeschlossen werden, da dein Konto im Besitz einer Organisation ist. Kontaktiere deinen Organisationsadministrator für weitere Details." }, "accountDeleted": { "message": "Konto gelöscht" @@ -2394,7 +2394,7 @@ "message": "E-Mail generieren" }, "generatorBoundariesHint": { - "message": "Value must be between $MIN$ and $MAX$", + "message": "Wert muss zwischen $MIN$ und $MAX$ liegen", "description": "Explains spin box minimum and maximum values to the user", "placeholders": { "min": { diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 83aaf2157d7..83fa064a35e 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -26,6 +26,9 @@ "typeSecureNote": { "message": "Secure note" }, + "typeSshKey": { + "message": "SSH key" + }, "folders": { "message": "Folders" }, @@ -177,6 +180,48 @@ "address": { "message": "Address" }, + "sshPrivateKey": { + "message": "Private key" + }, + "sshPublicKey": { + "message": "Public key" + }, + "sshFingerprint": { + "message": "Fingerprint" + }, + "sshKeyAlgorithm": { + "message": "Key type" + }, + "sshKeyAlgorithmED25519": { + "message": "ED25519" + }, + "sshKeyAlgorithmRSA2048": { + "message": "RSA 2048-Bit" + }, + "sshKeyAlgorithmRSA3072": { + "message": "RSA 3072-Bit" + }, + "sshKeyAlgorithmRSA4096": { + "message": "RSA 4096-Bit" + }, + "sshKeyGenerated": { + "message": "A new SSH key was generated" + }, + "sshAgentUnlockRequired": { + "message": "Please unlock your vault to approve the SSH key request." + }, + "sshAgentUnlockTimeout": { + "message": "SSH key request timed out." + }, + "enableSshAgent": { + "message": "Enable SSH agent" + }, + "enableSshAgentDesc": { + "message": "Enable the SSH agent to sign SSH requests right from your Bitwarden vault." + }, + "enableSshAgentHelp": { + "message": "The SSH agent is a service targeted at developers that allows you to sign SSH requests directly from your Bitwarden vault." + }, "premiumRequired": { "message": "Premium required" }, @@ -400,6 +445,12 @@ "copyPassword": { "message": "Copy password" }, + "regenerateSshKey": { + "message": "Regenerate SSH key" + }, + "copySshPrivateKey": { + "message": "Copy SSH private key" + }, "copyPassphrase": { "message": "Copy passphrase", "description": "Copy passphrase to clipboard" @@ -3225,6 +3276,36 @@ "ssoError": { "message": "No free ports could be found for the sso login." }, + "authorize": { + "message": "Authorize" + }, + "deny": { + "message": "Deny" + }, + "sshkeyApprovalTitle": { + "message": "Confirm SSH key usage" + }, + "sshkeyApprovalMessageInfix": { + "message": "is requesting access to" + }, + "unknownApplication": { + "message": "An application" + }, + "sshKeyPasswordUnsupported": { + "message": "Importing password protected SSH keys is not yet supported" + }, + "invalidSshKey": { + "message": "The SSH key is invalid" + }, + "sshKeyTypeUnsupported": { + "message": "The SSH key type is not supported" + }, + "importSshKeyFromClipboard": { + "message": "Import key from clipboard" + }, + "sshKeyPasted": { + "message": "SSH key imported successfully" + }, "fileSavedToDevice": { "message": "File saved to device. Manage from your device downloads." } diff --git a/apps/desktop/src/locales/fi/messages.json b/apps/desktop/src/locales/fi/messages.json index 063a63b1aca..cf6025e0423 100644 --- a/apps/desktop/src/locales/fi/messages.json +++ b/apps/desktop/src/locales/fi/messages.json @@ -1800,7 +1800,7 @@ "description": "Used as a card title description on the set password page to explain why the user is there" }, "cardMetrics": { - "message": "out of $TOTAL$", + "message": "/$TOTAL$", "placeholders": { "total": { "content": "$1", diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index a1b03509c70..f869898d57b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,6 +1,6 @@ import * as path from "path"; -import { app } from "electron"; +import { app, ipcMain } from "electron"; import { Subject, firstValueFrom } from "rxjs"; import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; @@ -38,6 +38,7 @@ import { WindowMain } from "./main/window.main"; import { ClipboardMain } from "./platform/main/clipboard.main"; import { DesktopCredentialStorageListener } from "./platform/main/desktop-credential-storage-listener"; import { MainCryptoFunctionService } from "./platform/main/main-crypto-function.service"; +import { MainSshAgentService } from "./platform/main/main-ssh-agent.service"; import { DesktopSettingsService } from "./platform/services/desktop-settings.service"; import { ElectronLogMainService } from "./platform/services/electron-log.main.service"; import { ElectronStorageService } from "./platform/services/electron-storage.service"; @@ -71,6 +72,7 @@ export class Main { nativeMessagingMain: NativeMessagingMain; clipboardMain: ClipboardMain; desktopAutofillSettingsService: DesktopAutofillSettingsService; + sshAgentService: MainSshAgentService; constructor() { // Set paths for portable builds @@ -240,6 +242,13 @@ export class Main { this.clipboardMain = new ClipboardMain(); this.clipboardMain.init(); + ipcMain.handle("sshagent.init", async (event: any, message: any) => { + if (this.sshAgentService == null) { + this.sshAgentService = new MainSshAgentService(this.logService, this.messagingService); + this.sshAgentService.init(); + } + }); + new EphemeralValueStorageService(); new SSOLocalhostCallbackService(this.environmentService, this.messagingService); } diff --git a/apps/desktop/src/main/window.main.ts b/apps/desktop/src/main/window.main.ts index 276a2bdc979..6d42e519d82 100644 --- a/apps/desktop/src/main/window.main.ts +++ b/apps/desktop/src/main/window.main.ts @@ -69,6 +69,19 @@ export class WindowMain { this.logService.info("Render process reloaded"); }); + ipcMain.on("window-focus", () => { + if (this.win != null) { + this.win.show(); + this.win.focus(); + } + }); + + ipcMain.on("window-hide", () => { + if (this.win != null) { + this.win.hide(); + } + }); + return new Promise((resolve, reject) => { try { if (!isMacAppStore() && !isSnapStore()) { diff --git a/apps/desktop/src/platform/components/approve-ssh-request.html b/apps/desktop/src/platform/components/approve-ssh-request.html new file mode 100644 index 00000000000..eac451a1fbe --- /dev/null +++ b/apps/desktop/src/platform/components/approve-ssh-request.html @@ -0,0 +1,17 @@ + + + {{ "sshkeyApprovalTitle" | i18n }} + + {{params.applicationName}} {{ "sshkeyApprovalMessageInfix" | i18n }} + {{params.cipherName}}. + + + + {{ "authorize" | i18n }} + + + {{ "deny" | i18n }} + + + + diff --git a/apps/desktop/src/platform/components/approve-ssh-request.ts b/apps/desktop/src/platform/components/approve-ssh-request.ts new file mode 100644 index 00000000000..62200962dca --- /dev/null +++ b/apps/desktop/src/platform/components/approve-ssh-request.ts @@ -0,0 +1,59 @@ +import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component, Inject } from "@angular/core"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { + AsyncActionsModule, + ButtonModule, + DialogModule, + FormFieldModule, + IconButtonModule, +} from "@bitwarden/components"; +import { DialogService } from "@bitwarden/components/src/dialog"; +import { CipherFormGeneratorComponent } from "@bitwarden/vault"; + +export interface ApproveSshRequestParams { + cipherName: string; + applicationName: string; +} + +@Component({ + selector: "app-approve-ssh-request", + templateUrl: "approve-ssh-request.html", + standalone: true, + imports: [ + DialogModule, + CommonModule, + JslibModule, + CipherFormGeneratorComponent, + ButtonModule, + IconButtonModule, + ReactiveFormsModule, + AsyncActionsModule, + FormFieldModule, + ], +}) +export class ApproveSshRequestComponent { + approveSshRequestForm = this.formBuilder.group({}); + + constructor( + @Inject(DIALOG_DATA) protected params: ApproveSshRequestParams, + private dialogRef: DialogRef, + private formBuilder: FormBuilder, + ) {} + + static open(dialogService: DialogService, cipherName: string, applicationName: string) { + return dialogService.open(ApproveSshRequestComponent, { + data: { + cipherName, + applicationName, + }, + }); + } + + submit = async () => { + this.dialogRef.close(true); + }; +} diff --git a/apps/desktop/src/platform/main/main-ssh-agent.service.ts b/apps/desktop/src/platform/main/main-ssh-agent.service.ts new file mode 100644 index 00000000000..c8227e5b6a5 --- /dev/null +++ b/apps/desktop/src/platform/main/main-ssh-agent.service.ts @@ -0,0 +1,115 @@ +import { ipcMain } from "electron"; +import { concatMap, delay, filter, firstValueFrom, from, race, take, timer } from "rxjs"; + +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { sshagent } from "@bitwarden/desktop-napi"; + +class AgentResponse { + requestId: number; + accepted: boolean; + timestamp: Date; +} + +export class MainSshAgentService { + SIGN_TIMEOUT = 60_000; + REQUEST_POLL_INTERVAL = 50; + + private requestResponses: AgentResponse[] = []; + private request_id = 0; + private agentState: sshagent.SshAgentState; + + constructor( + private logService: LogService, + private messagingService: MessagingService, + ) {} + + init() { + // handle sign request passing to UI + sshagent + .serve(async (err: Error, cipherId: string) => { + // clear all old (> SIGN_TIMEOUT) requests + this.requestResponses = this.requestResponses.filter( + (response) => response.timestamp > new Date(Date.now() - this.SIGN_TIMEOUT), + ); + + this.request_id += 1; + const id_for_this_request = this.request_id; + this.messagingService.send("sshagent.signrequest", { + cipherId, + requestId: id_for_this_request, + }); + + const result = await firstValueFrom( + race( + from([false]).pipe(delay(this.SIGN_TIMEOUT)), + + //poll for response + timer(0, this.REQUEST_POLL_INTERVAL).pipe( + concatMap(() => from(this.requestResponses)), + filter((response) => response.requestId == id_for_this_request), + take(1), + concatMap(() => from([true])), + ), + ), + ); + + if (!result) { + return false; + } + + const response = this.requestResponses.find( + (response) => response.requestId == id_for_this_request, + ); + + this.requestResponses = this.requestResponses.filter( + (response) => response.requestId != id_for_this_request, + ); + + return response.accepted; + }) + .then((agentState: sshagent.SshAgentState) => { + this.agentState = agentState; + this.logService.info("SSH agent started"); + }) + .catch((e) => { + this.logService.error("SSH agent encountered an error: ", e); + }); + + ipcMain.handle( + "sshagent.setkeys", + async (event: any, keys: { name: string; privateKey: string; cipherId: string }[]) => { + if (this.agentState != null) { + sshagent.setKeys(this.agentState, keys); + } + }, + ); + ipcMain.handle( + "sshagent.signrequestresponse", + async (event: any, { requestId, accepted }: { requestId: number; accepted: boolean }) => { + this.requestResponses.push({ requestId, accepted, timestamp: new Date() }); + }, + ); + ipcMain.handle( + "sshagent.generatekey", + async (event: any, { keyAlgorithm }: { keyAlgorithm: string }): Promise => { + return await sshagent.generateKeypair(keyAlgorithm); + }, + ); + ipcMain.handle( + "sshagent.importkey", + async ( + event: any, + { privateKey, password }: { privateKey: string; password?: string }, + ): Promise => { + return sshagent.importKey(privateKey, password); + }, + ); + + ipcMain.handle("sshagent.lock", async (event: any) => { + if (this.agentState != null) { + sshagent.lock(this.agentState); + } + }); + } +} diff --git a/apps/desktop/src/platform/preload.ts b/apps/desktop/src/platform/preload.ts index bf02ca697f0..35caeff27c8 100644 --- a/apps/desktop/src/platform/preload.ts +++ b/apps/desktop/src/platform/preload.ts @@ -1,3 +1,4 @@ +import { sshagent as ssh } from "desktop_native/napi"; import { ipcRenderer } from "electron"; import { DeviceType } from "@bitwarden/common/enums"; @@ -40,6 +41,30 @@ const clipboard = { write: (message: ClipboardWriteMessage) => ipcRenderer.invoke("clipboard.write", message), }; +const sshAgent = { + init: async () => { + await ipcRenderer.invoke("sshagent.init"); + }, + setKeys: (keys: { name: string; privateKey: string; cipherId: string }[]): Promise => + ipcRenderer.invoke("sshagent.setkeys", keys), + signRequestResponse: async (requestId: number, accepted: boolean) => { + await ipcRenderer.invoke("sshagent.signrequestresponse", { requestId, accepted }); + }, + generateKey: async (keyAlgorithm: string): Promise => { + return await ipcRenderer.invoke("sshagent.generatekey", { keyAlgorithm }); + }, + lock: async () => { + return await ipcRenderer.invoke("sshagent.lock"); + }, + importKey: async (key: string, password: string): Promise => { + const res = await ipcRenderer.invoke("sshagent.importkey", { + privateKey: key, + password: password, + }); + return res; + }, +}; + const powermonitor = { isLockMonitorAvailable: (): Promise => ipcRenderer.invoke("powermonitor.isLockMonitorAvailable"), @@ -106,6 +131,8 @@ export default { isSnapStore: isSnapStore(), isAppImage: isAppImage(), reloadProcess: () => ipcRenderer.send("reload-process"), + focusWindow: () => ipcRenderer.send("window-focus"), + hideWindow: () => ipcRenderer.send("window-hide"), log: (level: LogLevelType, message?: any, ...optionalParams: any[]) => ipcRenderer.invoke("ipc.log", { level, message, optionalParams }), @@ -150,6 +177,7 @@ export default { storage, passwords, clipboard, + sshAgent, powermonitor, nativeMessaging, crypto, diff --git a/apps/desktop/src/platform/services/desktop-settings.service.ts b/apps/desktop/src/platform/services/desktop-settings.service.ts index ff29ce50a0f..c698e7d5b1b 100644 --- a/apps/desktop/src/platform/services/desktop-settings.service.ts +++ b/apps/desktop/src/platform/services/desktop-settings.service.ts @@ -66,6 +66,10 @@ const BROWSER_INTEGRATION_FINGERPRINT_ENABLED = new KeyDefinition( }, ); +const SSH_AGENT_ENABLED = new KeyDefinition(DESKTOP_SETTINGS_DISK, "sshAgentEnabled", { + deserializer: (b) => b, +}); + const MINIMIZE_ON_COPY = new UserKeyDefinition(DESKTOP_SETTINGS_DISK, "minimizeOnCopy", { deserializer: (b) => b, clearOn: [], // User setting, no need to clear @@ -139,6 +143,10 @@ export class DesktopSettingsService { browserIntegrationFingerprintEnabled$ = this.browserIntegrationFingerprintEnabledState.state$.pipe(map(Boolean)); + private readonly sshAgentEnabledState = this.stateProvider.getGlobal(SSH_AGENT_ENABLED); + + sshAgentEnabled$ = this.sshAgentEnabledState.state$.pipe(map(Boolean)); + private readonly minimizeOnCopyState = this.stateProvider.getActive(MINIMIZE_ON_COPY); /** @@ -246,6 +254,13 @@ export class DesktopSettingsService { await this.browserIntegrationFingerprintEnabledState.update(() => value); } + /** + * Sets a setting for whether or not the SSH agent is enabled. + */ + async setSshAgentEnabled(value: boolean) { + await this.sshAgentEnabledState.update(() => value); + } + /** * Sets the minimize on copy value for the current user. * @param value `true` if the application should minimize when a value is copied, diff --git a/apps/desktop/src/platform/services/electron-log.service.spec.ts b/apps/desktop/src/platform/services/electron-log.service.spec.ts index dd3d1112fc1..918508977fd 100644 --- a/apps/desktop/src/platform/services/electron-log.service.spec.ts +++ b/apps/desktop/src/platform/services/electron-log.service.spec.ts @@ -2,7 +2,7 @@ import { ElectronLogMainService } from "./electron-log.main.service"; // Mock the use of the electron API to avoid errors jest.mock("electron", () => ({ - ipcMain: { handle: jest.fn() }, + ipcMain: { handle: jest.fn(), on: jest.fn() }, })); describe("ElectronLogMainService", () => { diff --git a/apps/desktop/src/platform/services/ssh-agent.service.ts b/apps/desktop/src/platform/services/ssh-agent.service.ts new file mode 100644 index 00000000000..dd518a943b8 --- /dev/null +++ b/apps/desktop/src/platform/services/ssh-agent.service.ts @@ -0,0 +1,183 @@ +import { Injectable, OnDestroy } from "@angular/core"; +import { + catchError, + combineLatest, + concatMap, + EMPTY, + filter, + from, + map, + of, + Subject, + switchMap, + takeUntil, + timeout, + TimeoutError, + timer, + withLatestFrom, +} from "rxjs"; + +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { CommandDefinition, MessageListener } from "@bitwarden/common/platform/messaging"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { DialogService, ToastService } from "@bitwarden/components"; + +import { ApproveSshRequestComponent } from "../components/approve-ssh-request"; + +import { DesktopSettingsService } from "./desktop-settings.service"; + +@Injectable({ + providedIn: "root", +}) +export class SshAgentService implements OnDestroy { + SSH_REFRESH_INTERVAL = 1000; + SSH_VAULT_UNLOCK_REQUEST_TIMEOUT = 1000 * 60; + SSH_REQUEST_UNLOCK_POLLING_INTERVAL = 100; + + private destroy$ = new Subject(); + + constructor( + private cipherService: CipherService, + private logService: LogService, + private dialogService: DialogService, + private messageListener: MessageListener, + private authService: AuthService, + private toastService: ToastService, + private i18nService: I18nService, + private desktopSettingsService: DesktopSettingsService, + private configService: ConfigService, + ) {} + + async init() { + const isSshAgentFeatureEnabled = await this.configService.getFeatureFlag(FeatureFlag.SSHAgent); + if (isSshAgentFeatureEnabled) { + await ipc.platform.sshAgent.init(); + + this.messageListener + .messages$(new CommandDefinition("sshagent.signrequest")) + .pipe( + withLatestFrom(this.authService.activeAccountStatus$), + // This switchMap handles unlocking the vault if it is locked: + // - If the vault is locked, we will wait for it to be unlocked. + // - If the vault is not unlocked within the timeout, we will abort the flow. + // - If the vault is unlocked, we will continue with the flow. + // switchMap is used here to prevent multiple requests from being processed at the same time, + // and will cancel the previous request if a new one is received. + switchMap(([message, status]) => { + if (status !== AuthenticationStatus.Unlocked) { + ipc.platform.focusWindow(); + this.toastService.showToast({ + variant: "info", + title: null, + message: this.i18nService.t("sshAgentUnlockRequired"), + }); + return this.authService.activeAccountStatus$.pipe( + filter((status) => status === AuthenticationStatus.Unlocked), + timeout(this.SSH_VAULT_UNLOCK_REQUEST_TIMEOUT), + catchError((error: unknown) => { + if (error instanceof TimeoutError) { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("sshAgentUnlockTimeout"), + }); + const requestId = message.requestId as number; + // Abort flow by sending a false response. + // Returning an empty observable this will prevent the rest of the flow from executing + return from(ipc.platform.sshAgent.signRequestResponse(requestId, false)).pipe( + map(() => EMPTY), + ); + } + + throw error; + }), + map(() => message), + ); + } + + return of(message); + }), + // This switchMap handles fetching the ciphers from the vault. + switchMap((message) => + from(this.cipherService.getAllDecrypted()).pipe( + map((ciphers) => [message, ciphers] as const), + ), + ), + // This concatMap handles showing the dialog to approve the request. + concatMap(([message, decryptedCiphers]) => { + const cipherId = message.cipherId as string; + const requestId = message.requestId as number; + + if (decryptedCiphers === undefined) { + return of(false).pipe( + switchMap((result) => + ipc.platform.sshAgent.signRequestResponse(requestId, Boolean(result)), + ), + ); + } + + const cipher = decryptedCiphers.find((cipher) => cipher.id == cipherId); + + ipc.platform.focusWindow(); + const dialogRef = ApproveSshRequestComponent.open( + this.dialogService, + cipher.name, + this.i18nService.t("unknownApplication"), + ); + + return dialogRef.closed.pipe( + switchMap((result) => { + return ipc.platform.sshAgent.signRequestResponse(requestId, Boolean(result)); + }), + ); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + + combineLatest([ + timer(0, this.SSH_REFRESH_INTERVAL), + this.desktopSettingsService.sshAgentEnabled$, + ]) + .pipe( + concatMap(async ([, enabled]) => { + if (!enabled) { + await ipc.platform.sshAgent.setKeys([]); + return; + } + + const ciphers = await this.cipherService.getAllDecrypted(); + if (ciphers == null) { + await ipc.platform.sshAgent.lock(); + return; + } + + const sshCiphers = ciphers.filter( + (cipher) => cipher.type === CipherType.SshKey && !cipher.isDeleted, + ); + const keys = sshCiphers.map((cipher) => { + return { + name: cipher.name, + privateKey: cipher.sshKey.privateKey, + cipherId: cipher.id, + }; + }); + await ipc.platform.sshAgent.setKeys(keys); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + } + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/apps/desktop/src/vault/app/vault/add-edit.component.html b/apps/desktop/src/vault/app/vault/add-edit.component.html index ea2884ba2a1..57b11928bef 100644 --- a/apps/desktop/src/vault/app/vault/add-edit.component.html +++ b/apps/desktop/src/vault/app/vault/add-edit.component.html @@ -11,7 +11,7 @@ {{ "type" | i18n }} - + {{ o.name }} @@ -471,6 +471,115 @@ /> + + + + + {{ "sshPrivateKey" | i18n }} + + + + + + + + + + + + + + + + + + {{ "sshPublicKey" | i18n }} + + + + + + + + + + + {{ "sshFingerprint" | i18n }} + + + + + + + + + + + {{ "importSshKeyFromClipboard" | i18n }} + + + diff --git a/apps/desktop/src/vault/app/vault/add-edit.component.ts b/apps/desktop/src/vault/app/vault/add-edit.component.ts index e34d9cbb412..a3a9c929159 100644 --- a/apps/desktop/src/vault/app/vault/add-edit.component.ts +++ b/apps/desktop/src/vault/app/vault/add-edit.component.ts @@ -1,6 +1,7 @@ import { DatePipe } from "@angular/common"; -import { Component, NgZone, OnChanges, OnInit, OnDestroy, ViewChild } from "@angular/core"; +import { Component, NgZone, OnChanges, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { NgForm } from "@angular/forms"; +import { sshagent as sshAgent } from "desktop_native/napi"; import { CollectionService } from "@bitwarden/admin-console/common"; import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/vault/components/add-edit.component"; @@ -18,8 +19,9 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; -import { DialogService } from "@bitwarden/components"; +import { DialogService, ToastService } from "@bitwarden/components"; import { PasswordRepromptService } from "@bitwarden/vault"; const BroadcasterSubscriptionId = "AddEditComponent"; @@ -31,6 +33,7 @@ const BroadcasterSubscriptionId = "AddEditComponent"; export class AddEditComponent extends BaseAddEditComponent implements OnInit, OnChanges, OnDestroy { @ViewChild("form") private form: NgForm; + constructor( cipherService: CipherService, folderService: FolderService, @@ -51,6 +54,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On dialogService: DialogService, datePipe: DatePipe, configService: ConfigService, + private toastService: ToastService, cipherAuthorizationService: CipherAuthorizationService, ) { super( @@ -140,4 +144,68 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On "https://bitwarden.com/help/managing-items/#protect-individual-items", ); } + + async generateSshKey() { + const sshKey = await ipc.platform.sshAgent.generateKey("ed25519"); + this.cipher.sshKey.privateKey = sshKey.privateKey; + this.cipher.sshKey.publicKey = sshKey.publicKey; + this.cipher.sshKey.keyFingerprint = sshKey.keyFingerprint; + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("sshKeyGenerated"), + }); + } + + async importSshKeyFromClipboard() { + const key = await this.platformUtilsService.readFromClipboard(); + const parsedKey = await ipc.platform.sshAgent.importKey(key, ""); + if (parsedKey == null || parsedKey.status === sshAgent.SshKeyImportStatus.ParsingError) { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("invalidSshKey"), + }); + return; + } else if (parsedKey.status === sshAgent.SshKeyImportStatus.UnsupportedKeyType) { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("sshKeyTypeUnsupported"), + }); + } else if ( + parsedKey.status === sshAgent.SshKeyImportStatus.PasswordRequired || + parsedKey.status === sshAgent.SshKeyImportStatus.WrongPassword + ) { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("sshKeyPasswordUnsupported"), + }); + return; + } else { + this.cipher.sshKey.privateKey = parsedKey.sshKey.privateKey; + this.cipher.sshKey.publicKey = parsedKey.sshKey.publicKey; + this.cipher.sshKey.keyFingerprint = parsedKey.sshKey.keyFingerprint; + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("sshKeyPasted"), + }); + } + } + + async typeChange() { + if (this.cipher.type === CipherType.SshKey) { + await this.generateSshKey(); + } + } + + truncateString(value: string, length: number) { + return value.length > length ? value.substring(0, length) + "..." : value; + } + + togglePrivateKey() { + this.showPrivateKey = !this.showPrivateKey; + } } diff --git a/apps/desktop/src/vault/app/vault/vault-filter/filters/type-filter.component.html b/apps/desktop/src/vault/app/vault/vault-filter/filters/type-filter.component.html index 381c06e8b67..c3dcd191dfc 100644 --- a/apps/desktop/src/vault/app/vault/vault-filter/filters/type-filter.component.html +++ b/apps/desktop/src/vault/app/vault/vault-filter/filters/type-filter.component.html @@ -79,4 +79,19 @@ + + + + {{ "typeSshKey" | i18n }} + + + diff --git a/apps/desktop/src/vault/app/vault/view.component.html b/apps/desktop/src/vault/app/vault/view.component.html index c855224cf9f..e6c20d2e89e 100644 --- a/apps/desktop/src/vault/app/vault/view.component.html +++ b/apps/desktop/src/vault/app/vault/view.component.html @@ -399,6 +399,105 @@ {{ cipher.identity.country }} + + + + + {{ "sshPrivateKey" | i18n }} + + + + + + + + + + + + + + + {{ "sshPublicKey" | i18n }} + + + + + + + + + + + {{ "sshFingerprint" | i18n }} + + + + + + + + + diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html index b2812727473..c77c8fc935f 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html @@ -262,7 +262,9 @@ {{ "revokeAccess" | i18n }} = this.configService.getFeatureFlag$( + FeatureFlag.AccountDeprovisioning, + ); + private destroy$ = new Subject(); get customUserTypeSelected(): boolean { @@ -145,6 +151,7 @@ export class MemberDialogComponent implements OnDestroy { private accountService: AccountService, organizationService: OrganizationService, private toastService: ToastService, + private configService: ConfigService, ) { this.organization$ = organizationService .get$(this.params.organizationId) diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.html b/apps/web/src/app/admin-console/organizations/members/members.component.html index e21e1f65923..f87934dbe81 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.html +++ b/apps/web/src/app/admin-console/organizations/members/members.component.html @@ -315,13 +315,18 @@ {{ "revokeAccess" | i18n }} - + {{ "remove" | i18n }} FeatureFlag.EnableUpgradePasswordManagerSub, ); + protected accountDeprovisioningEnabled$: Observable = this.configService.getFeatureFlag$( + FeatureFlag.AccountDeprovisioning, + ); + // Fixed sizes used for cdkVirtualScroll protected rowHeight = 62; protected rowHeightClass = `tw-h-[62px]`; diff --git a/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.ts b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.ts index 2c8b579b994..bc354009775 100644 --- a/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.ts +++ b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.ts @@ -46,7 +46,7 @@ export class SecretsManagerTrialFreeStepperComponent implements OnInit { protected formBuilder: UntypedFormBuilder, protected i18nService: I18nService, protected organizationBillingService: OrganizationBillingService, - private router: Router, + protected router: Router, ) {} ngOnInit(): void { diff --git a/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.html b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.html index 1acf4c32097..aeec49e5276 100644 --- a/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.html +++ b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.html @@ -22,12 +22,29 @@ bitButton buttonType="primary" [disabled]="formGroup.get('name').invalid" + [loading]="createOrganizationLoading" + (click)="createOrganizationOnTrial()" + *ngIf="enableTrialPayment$ | async" + > + {{ "startTrial" | i18n }} + + {{ "next" | i18n }} - + (); + protected enableTrialPayment$ = this.configService.getFeatureFlag$( + FeatureFlag.TrialPaymentOptional, + ); + + constructor( + private route: ActivatedRoute, + private configService: ConfigService, + protected formBuilder: UntypedFormBuilder, + protected i18nService: I18nService, + protected organizationBillingService: OrganizationBillingService, + protected router: Router, + ) { + super(formBuilder, i18nService, organizationBillingService, router); + } + + async ngOnInit(): Promise { + this.referenceEventRequest = new ReferenceEventRequest(); + this.referenceEventRequest.initiationPath = "Secrets Manager trial from marketing website"; + + this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((qParams) => { + if (trialFlowOrgs.includes(qParams.org)) { + if (qParams.org === ValidOrgParams.teamsStarter) { + this.plan = PlanType.TeamsStarter; + } else if (qParams.org === ValidOrgParams.teams) { + this.plan = PlanType.TeamsAnnually; + } else if (qParams.org === ValidOrgParams.enterprise) { + this.plan = PlanType.EnterpriseAnnually; + } + } + }); + } + organizationCreated(event: OrganizationCreatedEvent) { this.organizationId = event.organizationId; this.billingSubLabel = event.planDescription; @@ -31,6 +85,29 @@ export class SecretsManagerTrialPaidStepperComponent extends SecretsManagerTrial this.verticalStepper.previous(); } + async createOrganizationOnTrial(): Promise { + this.createOrganizationLoading = true; + const response = await this.organizationBillingService.purchaseSubscriptionNoPaymentMethod({ + organization: { + name: this.formGroup.get("name").value, + billingEmail: this.formGroup.get("email").value, + initiationPath: "Secrets Manager trial from marketing website", + }, + plan: { + type: this.plan, + subscribeToSecretsManager: true, + isFromSecretsManagerTrial: true, + passwordManagerSeats: 1, + secretsManagerSeats: 1, + }, + }); + + this.organizationId = response?.id; + this.subLabels.organizationInfo = response?.name; + this.createOrganizationLoading = false; + this.verticalStepper.next(); + } + get createAccountLabel() { const organizationType = this.productType === ProductTierType.TeamsStarter diff --git a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.html b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.html index ed1dc6cda9b..077836a7634 100644 --- a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.html +++ b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.html @@ -91,12 +91,17 @@ bitButton buttonType="primary" [disabled]="orgInfoFormGroup.get('name').invalid" - cdkStepperNext + [loading]="loading" + (click)="createOrganizationOnTrial()" > - {{ "next" | i18n }} + {{ (enableTrialPayment$ | async) ? ("startTrial" | i18n) : ("next" | i18n) }} - + { let policyServiceMock: MockProxy; let routerServiceMock: MockProxy; let acceptOrgInviteServiceMock: MockProxy; + let organizationBillingServiceMock: MockProxy; + let configServiceMock: MockProxy; beforeEach(() => { // only define services directly that we want to mock return values in this component @@ -47,6 +51,8 @@ describe("TrialInitiationComponent", () => { policyServiceMock = mock(); routerServiceMock = mock(); acceptOrgInviteServiceMock = mock(); + organizationBillingServiceMock = mock(); + configServiceMock = mock(); // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -92,6 +98,14 @@ describe("TrialInitiationComponent", () => { provide: AcceptOrganizationInviteService, useValue: acceptOrgInviteServiceMock, }, + { + provide: OrganizationBillingService, + useValue: organizationBillingServiceMock, + }, + { + provide: ConfigService, + useValue: configServiceMock, + }, ], schemas: [NO_ERRORS_SCHEMA], // Allows child components to be ignored (such as register component) }).compileComponents(); diff --git a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.ts b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.ts index f8718b0a420..7892283a387 100644 --- a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.ts +++ b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.ts @@ -9,8 +9,15 @@ import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abs import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { + OrganizationInformation, + PlanInformation, + OrganizationBillingServiceAbstraction as OrganizationBillingService, +} from "@bitwarden/common/billing/abstractions/organization-billing.service"; import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -25,7 +32,7 @@ import { OrganizationInvite } from "../organization-invite/organization-invite"; import { RouterService } from "./../../core/router.service"; import { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.component"; -enum ValidOrgParams { +export enum ValidOrgParams { families = "families", enterprise = "enterprise", teams = "teams", @@ -69,6 +76,7 @@ export class TrialInitiationComponent implements OnInit, OnDestroy { productTier: ProductTierType; accountCreateOnly = true; useTrialStepper = false; + loading = false; policies: Policy[]; enforcedPolicyOptions: MasterPasswordPolicyOptions; trialFlowOrgs: string[] = [ @@ -115,6 +123,9 @@ export class TrialInitiationComponent implements OnInit, OnDestroy { } private destroy$ = new Subject(); + protected enableTrialPayment$ = this.configService.getFeatureFlag$( + FeatureFlag.TrialPaymentOptional, + ); constructor( private route: ActivatedRoute, @@ -127,6 +138,8 @@ export class TrialInitiationComponent implements OnInit, OnDestroy { private i18nService: I18nService, private routerService: RouterService, private acceptOrgInviteService: AcceptOrganizationInviteService, + private organizationBillingService: OrganizationBillingService, + private configService: ConfigService, ) {} async ngOnInit(): Promise { @@ -215,6 +228,30 @@ export class TrialInitiationComponent implements OnInit, OnDestroy { } } + async createOrganizationOnTrial() { + this.loading = true; + const organization: OrganizationInformation = { + name: this.orgInfoFormGroup.get("name").value, + billingEmail: this.orgInfoFormGroup.get("email").value, + initiationPath: "Password Manager trial from marketing website", + }; + + const plan: PlanInformation = { + type: this.plan, + passwordManagerSeats: 1, + }; + + const response = await this.organizationBillingService.purchaseSubscriptionNoPaymentMethod({ + organization, + plan, + }); + + this.orgId = response?.id; + this.billingSubLabel = `${this.i18nService.t("annual")} ($0/${this.i18nService.t("yr")})`; + this.loading = false; + this.verticalStepper.next(); + } + createdAccount(email: string) { this.email = email; this.orgInfoFormGroup.get("email")?.setValue(email); diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html index e6ed6475c4a..878672a1fb9 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html @@ -345,16 +345,22 @@ 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 0ba4829c7c8..5a6ac8c896a 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 @@ -282,6 +282,12 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { : this.discountPercentageFromSub + this.discountPercentage; } + isPaymentSourceEmpty() { + return this.deprecateStripeSourcesAPI + ? this.paymentSource === null || this.paymentSource === undefined + : this.billing?.paymentSource === null || this.billing?.paymentSource === undefined; + } + isSecretsManagerTrial(): boolean { return ( this.sub?.subscription?.items?.some((item) => @@ -723,7 +729,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { // Secrets Manager this.buildSecretsManagerRequest(request); - if (this.upgradeRequiresPaymentMethod || this.showPayment) { + if (this.upgradeRequiresPaymentMethod || this.showPayment || this.isPaymentSourceEmpty()) { if (this.deprecateStripeSourcesAPI) { const tokenizedPaymentSource = await this.paymentV2Component.tokenize(); const updatePaymentMethodRequest = new UpdatePaymentMethodRequest(); diff --git a/apps/web/src/app/billing/organizations/organization-billing.module.ts b/apps/web/src/app/billing/organizations/organization-billing.module.ts index ccfe12b2e59..b25cda662f2 100644 --- a/apps/web/src/app/billing/organizations/organization-billing.module.ts +++ b/apps/web/src/app/billing/organizations/organization-billing.module.ts @@ -1,5 +1,6 @@ import { NgModule } from "@angular/core"; +import { BannerModule } from "../../../../../../libs/components/src/banner/banner.module"; import { UserVerificationModule } from "../../auth/shared/components/user-verification"; import { LooseComponentsModule } from "../../shared"; import { BillingSharedModule } from "../shared"; @@ -28,6 +29,7 @@ import { SubscriptionStatusComponent } from "./subscription-status.component"; BillingSharedModule, OrganizationPlansComponent, LooseComponentsModule, + BannerModule, ], declarations: [ AdjustSubscription, diff --git a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.html b/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.html index 9f9cb9efc65..7a6e8558bae 100644 --- a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.html +++ b/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.html @@ -1,3 +1,22 @@ + + {{ freeTrialData.message }} + + {{ "routeToPaymentMethodTrigger" | i18n }} + + diff --git a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts b/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts index 0756a6c314c..e2178e7c02c 100644 --- a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts +++ b/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts @@ -1,17 +1,25 @@ -import { Component, ViewChild } from "@angular/core"; +import { Location } from "@angular/common"; +import { Component, OnDestroy, ViewChild } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; import { from, lastValueFrom, switchMap } from "rxjs"; +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { PaymentMethodType } from "@bitwarden/common/billing/enums"; import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/request/verify-bank-account.request"; +import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; import { DialogService, ToastService } from "@bitwarden/components"; +import { FreeTrial } from "../../../core/types/free-trial"; +import { TrialFlowService } from "../../services/trial-flow.service"; import { TaxInfoComponent } from "../../shared"; import { AddCreditDialogResult, @@ -25,26 +33,36 @@ import { @Component({ templateUrl: "./organization-payment-method.component.html", }) -export class OrganizationPaymentMethodComponent { +export class OrganizationPaymentMethodComponent implements OnDestroy { @ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent; organizationId: string; + isUnpaid = false; accountCredit: number; paymentSource?: PaymentSourceResponse; subscriptionStatus?: string; + protected freeTrialData: FreeTrial; + organization: Organization; + organizationSubscriptionResponse: OrganizationSubscriptionResponse; loading = true; protected readonly Math = Math; + launchPaymentModalAutomatically = false; constructor( private activatedRoute: ActivatedRoute, private billingApiService: BillingApiServiceAbstraction, + protected organizationApiService: OrganizationApiServiceAbstraction, private dialogService: DialogService, private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, private router: Router, private toastService: ToastService, + private location: Location, + private trialFlowService: TrialFlowService, + private organizationService: OrganizationService, + protected syncService: SyncService, ) { this.activatedRoute.params .pipe( @@ -59,6 +77,23 @@ export class OrganizationPaymentMethodComponent { }), ) .subscribe(); + + const state = this.router.getCurrentNavigation()?.extras?.state; + // incase the above state is undefined or null we use redundantState + const redundantState: any = location.getState(); + if (state && Object.prototype.hasOwnProperty.call(state, "launchPaymentModalAutomatically")) { + this.launchPaymentModalAutomatically = state.launchPaymentModalAutomatically; + } else if ( + redundantState && + Object.prototype.hasOwnProperty.call(redundantState, "launchPaymentModalAutomatically") + ) { + this.launchPaymentModalAutomatically = redundantState.launchPaymentModalAutomatically; + } else { + this.launchPaymentModalAutomatically = false; + } + } + ngOnDestroy(): void { + this.launchPaymentModalAutomatically = false; } protected addAccountCredit = async (): Promise => { @@ -82,6 +117,34 @@ export class OrganizationPaymentMethodComponent { this.accountCredit = accountCredit; this.paymentSource = paymentSource; this.subscriptionStatus = subscriptionStatus; + + if (this.organizationId) { + const organizationSubscriptionPromise = this.organizationApiService.getSubscription( + this.organizationId, + ); + const organizationPromise = this.organizationService.get(this.organizationId); + + [this.organizationSubscriptionResponse, this.organization] = await Promise.all([ + organizationSubscriptionPromise, + organizationPromise, + ]); + this.freeTrialData = this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues( + this.organization, + this.organizationSubscriptionResponse, + paymentSource, + ); + } + this.isUnpaid = this.subscriptionStatus === "unpaid" ?? false; + // If the flag `launchPaymentModalAutomatically` is set to true, + // we schedule a timeout (delay of 800ms) to automatically launch the payment modal. + // This delay ensures that any prior UI/rendering operations complete before triggering the modal. + if (this.launchPaymentModalAutomatically) { + window.setTimeout(async () => { + await this.changePayment(); + this.launchPaymentModalAutomatically = false; + this.location.replaceState(this.location.path(), "", {}); + }, 800); + } this.loading = false; }; @@ -100,6 +163,24 @@ export class OrganizationPaymentMethodComponent { } }; + changePayment = async () => { + const dialogRef = AdjustPaymentDialogV2Component.open(this.dialogService, { + data: { + initialPaymentMethod: this.paymentSource?.type, + organizationId: this.organizationId, + }, + }); + const result = await lastValueFrom(dialogRef.closed); + if (result === AdjustPaymentDialogV2ResultType.Submitted) { + this.location.replaceState(this.location.path(), "", {}); + if (this.launchPaymentModalAutomatically && !this.organization.enabled) { + await this.syncService.fullSync(true); + } + this.launchPaymentModalAutomatically = false; + await this.load(); + } + }; + protected updateTaxInformation = async (): Promise => { this.taxInfoComponent.taxFormGroup.updateValueAndValidity(); this.taxInfoComponent.taxFormGroup.markAllAsTouched(); diff --git a/apps/web/src/app/billing/services/trial-flow.service.ts b/apps/web/src/app/billing/services/trial-flow.service.ts new file mode 100644 index 00000000000..3135a811665 --- /dev/null +++ b/apps/web/src/app/billing/services/trial-flow.service.ts @@ -0,0 +1,100 @@ +import { Injectable } from "@angular/core"; +import { Router } from "@angular/router"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; +import { BillingSourceResponse } from "@bitwarden/common/billing/models/response/billing.response"; +import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; +import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; +import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DialogService } from "@bitwarden/components"; + +import { FreeTrial } from "../../core/types/free-trial"; + +@Injectable({ providedIn: "root" }) +export class TrialFlowService { + constructor( + private i18nService: I18nService, + protected dialogService: DialogService, + private router: Router, + protected billingApiService: BillingApiServiceAbstraction, + ) {} + checkForOrgsWithUpcomingPaymentIssues( + organization: Organization, + organizationSubscription: OrganizationSubscriptionResponse, + paymentSource: BillingSourceResponse | PaymentSourceResponse, + ): FreeTrial { + const trialEndDate = organizationSubscription?.subscription?.trialEndDate; + const displayBanner = + !paymentSource && + organization?.isOwner && + organizationSubscription?.subscription?.status === "trialing"; + const trialRemainingDays = trialEndDate ? this.calculateTrialRemainingDays(trialEndDate) : 0; + const freeTrialMessage = this.getFreeTrialMessage(trialRemainingDays); + + return { + remainingDays: trialRemainingDays, + message: freeTrialMessage, + shownBanner: displayBanner, + organizationId: organization.id, + organizationName: organization.name, + }; + } + + calculateTrialRemainingDays(trialEndDate: string): number | undefined { + const today = new Date(); + const trialEnd = new Date(trialEndDate); + const timeDifference = trialEnd.getTime() - today.getTime(); + + return Math.ceil(timeDifference / (1000 * 60 * 60 * 24)); + } + + getFreeTrialMessage(trialRemainingDays: number): string { + if (trialRemainingDays >= 2) { + return this.i18nService.t("freeTrialEndPrompt", trialRemainingDays); + } else if (trialRemainingDays === 1) { + return this.i18nService.t("freeTrialEndPromptForOneDayNoOrgName"); + } else { + return this.i18nService.t("freeTrialEndingSoonWithoutOrgName"); + } + } + + async handleUnpaidSubscriptionDialog( + org: Organization, + organizationBillingMetadata: OrganizationBillingMetadataResponse, + ): Promise { + if (organizationBillingMetadata.isSubscriptionUnpaid) { + const confirmed = await this.promptForPaymentNavigation(org); + if (confirmed) { + await this.navigateToPaymentMethod(org?.id); + } + } + } + + private async promptForPaymentNavigation(org: Organization): Promise { + if (!org?.isOwner) { + await this.dialogService.openSimpleDialog({ + title: this.i18nService.t("suspendedOrganizationTitle", org?.name), + content: { key: "suspendedUserOrgMessage" }, + type: "danger", + acceptButtonText: this.i18nService.t("close"), + cancelButtonText: null, + }); + return false; + } + return await this.dialogService.openSimpleDialog({ + title: this.i18nService.t("suspendedOrganizationTitle", org?.name), + content: { key: "suspendedOwnerOrgMessage" }, + type: "danger", + acceptButtonText: this.i18nService.t("continue"), + cancelButtonText: this.i18nService.t("close"), + }); + } + + private async navigateToPaymentMethod(orgId: string) { + await this.router.navigate(["organizations", `${orgId}`, "billing", "payment-method"], { + state: { launchPaymentModalAutomatically: true }, + }); + } +} diff --git a/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.ts b/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.ts index 450c1234567..0c8e93531ee 100644 --- a/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.ts +++ b/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.ts @@ -74,6 +74,7 @@ export class AdjustPaymentDialogComponent { } }); await response; + await new Promise((resolve) => setTimeout(resolve, 10000)); this.toastService.showToast({ variant: "success", title: null, diff --git a/apps/web/src/app/billing/shared/billing-shared.module.ts b/apps/web/src/app/billing/shared/billing-shared.module.ts index 57491a73e6d..b9c235943ad 100644 --- a/apps/web/src/app/billing/shared/billing-shared.module.ts +++ b/apps/web/src/app/billing/shared/billing-shared.module.ts @@ -1,5 +1,7 @@ import { NgModule } from "@angular/core"; +import { BannerModule } from "@bitwarden/components"; + import { HeaderModule } from "../../layouts/header/header.module"; import { SharedModule } from "../../shared"; @@ -27,6 +29,7 @@ import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-ac PaymentComponent, TaxInfoComponent, HeaderModule, + BannerModule, PaymentV2Component, VerifyBankAccountComponent, ], diff --git a/apps/web/src/app/billing/shared/payment-method.component.html b/apps/web/src/app/billing/shared/payment-method.component.html index 495785af45f..1d4675847a1 100644 --- a/apps/web/src/app/billing/shared/payment-method.component.html +++ b/apps/web/src/app/billing/shared/payment-method.component.html @@ -1,3 +1,23 @@ + + {{ freeTrialData?.message }} + + {{ "routeToPaymentMethodTrigger" | i18n }} + + + - + {{ (paymentSource ? "changePaymentMethod" : "addPaymentMethod") | i18n }} diff --git a/apps/web/src/app/billing/shared/payment-method.component.ts b/apps/web/src/app/billing/shared/payment-method.component.ts index 06acf2142a5..98e6efcd8bd 100644 --- a/apps/web/src/app/billing/shared/payment-method.component.ts +++ b/apps/web/src/app/billing/shared/payment-method.component.ts @@ -1,10 +1,13 @@ -import { Component, OnInit, ViewChild } from "@angular/core"; +import { Location } from "@angular/common"; +import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { FormBuilder, FormControl, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; import { lastValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { PaymentMethodType } from "@bitwarden/common/billing/enums"; import { BillingPaymentResponse } from "@bitwarden/common/billing/models/response/billing-payment.response"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; @@ -13,8 +16,12 @@ import { VerifyBankRequest } from "@bitwarden/common/models/request/verify-bank. import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; import { DialogService, ToastService } from "@bitwarden/components"; +import { FreeTrial } from "../../core/types/free-trial"; +import { TrialFlowService } from "../services/trial-flow.service"; + import { AddCreditDialogResult, openAddCreditDialog } from "./add-credit-dialog.component"; import { AdjustPaymentDialogResult, @@ -26,7 +33,7 @@ import { TaxInfoComponent } from "./tax-info.component"; templateUrl: "payment-method.component.html", }) // eslint-disable-next-line rxjs-angular/prefer-takeuntil -export class PaymentMethodComponent implements OnInit { +export class PaymentMethodComponent implements OnInit, OnDestroy { @ViewChild(TaxInfoComponent) taxInfo: TaxInfoComponent; loading = false; @@ -37,6 +44,7 @@ export class PaymentMethodComponent implements OnInit { paymentMethodType = PaymentMethodType; organizationId: string; isUnpaid = false; + organization: Organization; verifyBankForm = this.formBuilder.group({ amount1: new FormControl(null, [ @@ -52,6 +60,8 @@ export class PaymentMethodComponent implements OnInit { }); taxForm = this.formBuilder.group({}); + launchPaymentModalAutomatically = false; + protected freeTrialData: FreeTrial; constructor( protected apiService: ApiService, @@ -59,12 +69,30 @@ export class PaymentMethodComponent implements OnInit { protected i18nService: I18nService, protected platformUtilsService: PlatformUtilsService, private router: Router, + private location: Location, private logService: LogService, private route: ActivatedRoute, private formBuilder: FormBuilder, private dialogService: DialogService, private toastService: ToastService, - ) {} + private trialFlowService: TrialFlowService, + private organizationService: OrganizationService, + protected syncService: SyncService, + ) { + const state = this.router.getCurrentNavigation()?.extras?.state; + // incase the above state is undefined or null we use redundantState + const redundantState: any = location.getState(); + if (state && Object.prototype.hasOwnProperty.call(state, "launchPaymentModalAutomatically")) { + this.launchPaymentModalAutomatically = state.launchPaymentModalAutomatically; + } else if ( + redundantState && + Object.prototype.hasOwnProperty.call(redundantState, "launchPaymentModalAutomatically") + ) { + this.launchPaymentModalAutomatically = redundantState.launchPaymentModalAutomatically; + } else { + this.launchPaymentModalAutomatically = false; + } + } async ngOnInit() { // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe @@ -88,27 +116,37 @@ export class PaymentMethodComponent implements OnInit { return; } this.loading = true; - if (this.forOrganization) { const billingPromise = this.organizationApiService.getBilling(this.organizationId); const organizationSubscriptionPromise = this.organizationApiService.getSubscription( this.organizationId, ); + const organizationPromise = this.organizationService.get(this.organizationId); - [this.billing, this.org] = await Promise.all([ + [this.billing, this.org, this.organization] = await Promise.all([ billingPromise, organizationSubscriptionPromise, + organizationPromise, ]); + this.determineOrgsWithUpcomingPaymentIssues(); } else { const billingPromise = this.apiService.getUserBillingPayment(); const subPromise = this.apiService.getUserSubscription(); [this.billing, this.sub] = await Promise.all([billingPromise, subPromise]); } - this.isUnpaid = this.subscription?.status === "unpaid" ?? false; - this.loading = false; + // If the flag `launchPaymentModalAutomatically` is set to true, + // we schedule a timeout (delay of 800ms) to automatically launch the payment modal. + // This delay ensures that any prior UI/rendering operations complete before triggering the modal. + if (this.launchPaymentModalAutomatically) { + window.setTimeout(async () => { + await this.changePayment(); + this.launchPaymentModalAutomatically = false; + this.location.replaceState(this.location.path(), "", {}); + }, 800); + } }; addCredit = async () => { @@ -132,6 +170,11 @@ export class PaymentMethodComponent implements OnInit { }); const result = await lastValueFrom(dialogRef.closed); if (result === AdjustPaymentDialogResult.Adjusted) { + this.location.replaceState(this.location.path(), "", {}); + if (this.launchPaymentModalAutomatically && !this.organization.enabled) { + await this.syncService.fullSync(true); + } + this.launchPaymentModalAutomatically = false; await this.load(); } }; @@ -162,6 +205,14 @@ export class PaymentMethodComponent implements OnInit { }); }; + determineOrgsWithUpcomingPaymentIssues() { + this.freeTrialData = this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues( + this.organization, + this.org, + this.billing?.paymentSource, + ); + } + get isCreditBalance() { return this.billing == null || this.billing.balance <= 0; } @@ -203,4 +254,8 @@ export class PaymentMethodComponent implements OnInit { get subscription() { return this.sub?.subscription ?? this.org?.subscription ?? null; } + + ngOnDestroy(): void { + this.launchPaymentModalAutomatically = false; + } } diff --git a/apps/web/src/app/core/event.service.ts b/apps/web/src/app/core/event.service.ts index 0bfe1d0dc89..be6a62443d3 100644 --- a/apps/web/src/app/core/event.service.ts +++ b/apps/web/src/app/core/event.service.ts @@ -330,6 +330,20 @@ export class EventService { this.getShortId(ev.organizationUserId), ); break; + case EventType.OrganizationUser_Deleted: + msg = this.i18nService.t("deletedUserId", this.formatOrgUserId(ev)); + humanReadableMsg = this.i18nService.t( + "deletedUserId", + this.getShortId(ev.organizationUserId), + ); + break; + case EventType.OrganizationUser_Left: + msg = this.i18nService.t("userLeftOrganization", this.formatOrgUserId(ev)); + humanReadableMsg = this.i18nService.t( + "userLeftOrganization", + this.getShortId(ev.organizationUserId), + ); + break; // Org case EventType.Organization_Updated: msg = humanReadableMsg = this.i18nService.t("editedOrgSettings"); diff --git a/apps/web/src/app/core/types/free-trial.ts b/apps/web/src/app/core/types/free-trial.ts new file mode 100644 index 00000000000..ee5fb921621 --- /dev/null +++ b/apps/web/src/app/core/types/free-trial.ts @@ -0,0 +1,7 @@ +export type FreeTrial = { + remainingDays: number; + message: string; + shownBanner: boolean; + organizationId: string; + organizationName: string; +}; 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 1dd03d03230..f34d32f5983 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,6 +22,7 @@ [route]="['../', org.id]" (mainContentClicked)="toggle()" [routerLinkActiveOptions]="{ exact: true }" + (click)="handleUnpaidSubscription(org)" > - + {{ "application" | i18n }} {{ "atRiskPasswords" | i18n }} {{ "totalPasswords" | i18n }} @@ -78,7 +79,7 @@ - + - + {{ "criticalApplicationsWithCount" | i18n: criticalApps.length }} diff --git a/apps/web/src/app/tools/risk-insights/risk-insights.component.ts b/apps/web/src/app/tools/risk-insights/risk-insights.component.ts index 43d6da70e96..1c6a36b4454 100644 --- a/apps/web/src/app/tools/risk-insights/risk-insights.component.ts +++ b/apps/web/src/app/tools/risk-insights/risk-insights.component.ts @@ -1,9 +1,11 @@ import { CommonModule } from "@angular/common"; -import { Component } from "@angular/core"; +import { Component, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { AsyncActionsModule, ButtonModule, TabsModule } from "@bitwarden/components"; import { HeaderModule } from "../../layouts/header/header.module"; @@ -39,9 +41,10 @@ export enum RiskInsightsTabType { TabsModule, ], }) -export class RiskInsightsComponent { +export class RiskInsightsComponent implements OnInit { tabIndex: RiskInsightsTabType; dataLastUpdated = new Date(); + isCritialAppsFeatureEnabled = false; apps: any[] = []; criticalApps: any[] = []; @@ -65,9 +68,16 @@ export class RiskInsightsComponent { }); }; + async ngOnInit() { + this.isCritialAppsFeatureEnabled = await this.configService.getFeatureFlag( + FeatureFlag.CriticalApps, + ); + } + constructor( protected route: ActivatedRoute, private router: Router, + private configService: ConfigService, ) { route.queryParams.pipe(takeUntilDestroyed()).subscribe(({ tabIndex }) => { this.tabIndex = !isNaN(tabIndex) ? tabIndex : RiskInsightsTabType.AllApps; 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 ae2cf88fd1f..df575cc525f 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 @@ -6,6 +6,7 @@ import { firstValueFrom, Observable, Subject } from "rxjs"; import { map } from "rxjs/operators"; import { CollectionView } 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 { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; @@ -17,6 +18,8 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { @@ -231,6 +234,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { private billingAccountProfileStateService: BillingAccountProfileStateService, private premiumUpgradeService: PremiumUpgradePromptService, private cipherAuthorizationService: CipherAuthorizationService, + private apiService: ApiService, ) { this.updateTitle(); } @@ -278,7 +282,20 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { if (this._originalFormMode === "add" || this._originalFormMode === "clone") { this.formConfig.mode = "edit"; } - this.formConfig.originalCipher = await this.cipherService.get(cipherView.id); + + let cipher: Cipher; + + // When the form config is used within the Admin Console, retrieve the cipher from the admin endpoint + if (this.formConfig.isAdminConsole) { + const cipherResponse = await this.apiService.getCipherAdmin(cipherView.id); + const cipherData = new CipherData(cipherResponse); + cipher = new Cipher(cipherData); + } else { + cipher = await this.cipherService.get(cipherView.id); + } + + // Store the updated cipher so any following edits use the most up to date cipher + this.formConfig.originalCipher = cipher; this._cipherModified = true; await this.changeMode("view"); } @@ -449,7 +466,14 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { * Helper method to delete cipher. */ private async deleteCipher(): Promise { - const asAdmin = this.organization?.canEditAllCiphers; + const cipherIsUnassigned = + !this.cipher.collectionIds || this.cipher.collectionIds?.length === 0; + + // Delete the cipher as an admin when: + // - the organization allows for owners/admins to manage all collections/items + // - the cipher is unassigned + const asAdmin = this.organization?.canEditAllCiphers || cipherIsUnassigned; + if (this.cipher.isDeleted) { await this.cipherService.deleteWithServer(this.cipher.id, asAdmin); } else { diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.html b/apps/web/src/app/vault/components/vault-items/vault-items.component.html index d6bcd76903b..653d05ef129 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.html @@ -16,13 +16,39 @@ "all" | i18n }} - {{ "name" | i18n }} + + + {{ "name" | i18n }} + + + + {{ "name" | i18n }} + {{ "owner" | i18n }} {{ "collections" | i18n }} - {{ "groups" | i18n }} - + + {{ "groups" | i18n }} + + {{ "permission" | i18n }} diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts index 71a97f1ff44..9f19a0319a5 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -1,13 +1,17 @@ import { SelectionModel } from "@angular/cdk/collections"; import { Component, EventEmitter, Input, Output } from "@angular/core"; -import { CollectionView, Unassigned } from "@bitwarden/admin-console/common"; +import { CollectionView, Unassigned, CollectionAdminView } from "@bitwarden/admin-console/common"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { TableDataSource } from "@bitwarden/components"; +import { SortDirection, TableDataSource } from "@bitwarden/components"; import { GroupView } from "../../../admin-console/organizations/core"; +import { + CollectionPermission, + convertToPermission, +} from "./../../../admin-console/organizations/shared/components/access-selector/access-selector.models"; import { VaultItem } from "./vault-item"; import { VaultItemEvent } from "./vault-item-event"; @@ -17,6 +21,8 @@ export const RowHeightClass = `tw-h-[65px]`; const MaxSelectionCount = 500; +type ItemPermission = CollectionPermission | "NoAccess"; + @Component({ selector: "app-vault-items", templateUrl: "vault-items.component.html", @@ -333,6 +339,119 @@ export class VaultItemsComponent { return (canEditOrManageAllCiphers || this.allCiphersHaveEditAccess()) && collectionNotSelected; } + /** + * Sorts VaultItems, grouping collections before ciphers, and sorting each group alphabetically by name. + */ + protected sortByName = (a: VaultItem, b: VaultItem, direction: SortDirection) => { + // Collections before ciphers + const collectionCompare = this.prioritizeCollections(a, b, direction); + if (collectionCompare !== 0) { + return collectionCompare; + } + + return this.compareNames(a, b); + }; + + /** + * Sorts VaultItems based on group names + */ + protected sortByGroups = (a: VaultItem, b: VaultItem, direction: SortDirection) => { + if ( + !(a.collection instanceof CollectionAdminView) && + !(b.collection instanceof CollectionAdminView) + ) { + return 0; + } + + const getFirstGroupName = (collection: CollectionAdminView): string => { + if (collection.groups.length > 0) { + return collection.groups.map((group) => this.getGroupName(group.id) || "").sort()[0]; + } + return null; + }; + + // Collections before ciphers + const collectionCompare = this.prioritizeCollections(a, b, direction); + if (collectionCompare !== 0) { + return collectionCompare; + } + + const aGroupName = getFirstGroupName(a.collection as CollectionAdminView); + const bGroupName = getFirstGroupName(b.collection as CollectionAdminView); + + // Collections with groups come before collections without groups. + // If a collection has no groups, getFirstGroupName returns null. + if (aGroupName === null) { + return 1; + } + + if (bGroupName === null) { + return -1; + } + + return aGroupName.localeCompare(bGroupName); + }; + + /** + * Sorts VaultItems based on their permissions, with higher permissions taking precedence. + * If permissions are equal, it falls back to sorting by name. + */ + protected sortByPermissions = (a: VaultItem, b: VaultItem, direction: SortDirection) => { + const getPermissionPriority = (item: VaultItem): number => { + const permission = item.collection + ? this.getCollectionPermission(item.collection) + : this.getCipherPermission(item.cipher); + + const priorityMap = { + [CollectionPermission.Manage]: 5, + [CollectionPermission.Edit]: 4, + [CollectionPermission.EditExceptPass]: 3, + [CollectionPermission.View]: 2, + [CollectionPermission.ViewExceptPass]: 1, + NoAccess: 0, + }; + + return priorityMap[permission] ?? -1; + }; + + // Collections before ciphers + const collectionCompare = this.prioritizeCollections(a, b, direction); + if (collectionCompare !== 0) { + return collectionCompare; + } + + const priorityA = getPermissionPriority(a); + const priorityB = getPermissionPriority(b); + + // Higher priority first + if (priorityA !== priorityB) { + return priorityA - priorityB; + } + + return this.compareNames(a, b); + }; + + private compareNames(a: VaultItem, b: VaultItem): number { + const getName = (item: VaultItem) => item.collection?.name || item.cipher?.name; + return getName(a).localeCompare(getName(b)); + } + + /** + * Sorts VaultItems by prioritizing collections over ciphers. + * Collections are always placed before ciphers, regardless of the sorting direction. + */ + private prioritizeCollections(a: VaultItem, b: VaultItem, direction: SortDirection): number { + if (a.collection && !b.collection) { + return direction === "asc" ? -1 : 1; + } + + if (!a.collection && b.collection) { + return direction === "asc" ? 1 : -1; + } + + return 0; + } + private hasPersonalItems(): boolean { return this.selection.selected.some(({ cipher }) => cipher?.organizationId === null); } @@ -346,4 +465,58 @@ export class VaultItemsComponent { private getUniqueOrganizationIds(): Set { return new Set(this.selection.selected.flatMap((i) => i.cipher?.organizationId ?? [])); } + + private getGroupName(groupId: string): string | undefined { + return this.allGroups.find((g) => g.id === groupId)?.name; + } + + private getCollectionPermission(collection: CollectionView): ItemPermission { + const organization = this.allOrganizations.find((o) => o.id === collection.organizationId); + + if (collection.id == Unassigned && organization?.canEditUnassignedCiphers) { + return CollectionPermission.Edit; + } + + if (collection.assigned) { + return convertToPermission(collection); + } + + return "NoAccess"; + } + + private getCipherPermission(cipher: CipherView): ItemPermission { + if (!cipher.organizationId || cipher.collectionIds.length === 0) { + return CollectionPermission.Manage; + } + + const filteredCollections = this.allCollections?.filter((collection) => { + if (collection.assigned) { + return cipher.collectionIds.find((id) => { + if (collection.id === id) { + return collection; + } + }); + } + }); + + if (filteredCollections?.length === 1) { + return convertToPermission(filteredCollections[0]); + } + + if (filteredCollections?.length > 0) { + const permissions = filteredCollections.map((collection) => convertToPermission(collection)); + + const orderedPermissions = [ + CollectionPermission.Manage, + CollectionPermission.Edit, + CollectionPermission.EditExceptPass, + CollectionPermission.View, + CollectionPermission.ViewExceptPass, + ]; + + return orderedPermissions.find((perm) => permissions.includes(perm)); + } + + return "NoAccess"; + } } diff --git a/apps/web/src/app/vault/individual-vault/add-edit.component.html b/apps/web/src/app/vault/individual-vault/add-edit.component.html index 855b5dac489..01ac60fc7e6 100644 --- a/apps/web/src/app/vault/individual-vault/add-edit.component.html +++ b/apps/web/src/app/vault/individual-vault/add-edit.component.html @@ -851,6 +851,99 @@ + + + + + {{ "sshKeyPrivateKey" | i18n }} + + + + + + + + + + + + + + {{ "sshKeyPublicKey" | i18n }} + + + + + + + + + + + {{ "sshKeyFingerprint" | i18n }} + + + + + + + + + + + {{ "notes" | i18n }} + {{ freeTrialMessage(organization) }} + + {{ "routeToPaymentMethodTrigger" | i18n }} + + + ; VisibleVaultBanner = VisibleVaultBanner; + @Input() organizationsPaymentStatus: FreeTrial[] = []; - constructor(private vaultBannerService: VaultBannersService) { + constructor( + private vaultBannerService: VaultBannersService, + private router: Router, + private i18nService: I18nService, + ) { this.premiumBannerVisible$ = this.vaultBannerService.shouldShowPremiumBanner$; } @@ -34,6 +42,17 @@ export class VaultBannersComponent implements OnInit { await this.determineVisibleBanners(); } + async navigateToPaymentMethod(organizationId: string): Promise { + const navigationExtras = { + state: { launchPaymentModalAutomatically: true }, + }; + + await this.router.navigate( + ["organizations", organizationId, "billing", "payment-method"], + navigationExtras, + ); + } + /** Determine which banners should be present */ private async determineVisibleBanners(): Promise { const showBrowserOutdated = await this.vaultBannerService.shouldShowUpdateBrowserBanner(); @@ -46,4 +65,22 @@ export class VaultBannersComponent implements OnInit { showLowKdf ? VisibleVaultBanner.KDFSettings : null, ].filter(Boolean); // remove all falsy values, i.e. null } + + freeTrialMessage(organization: FreeTrial) { + if (organization.remainingDays >= 2) { + return this.i18nService.t( + "freeTrialEndPromptAboveTwoDays", + organization.organizationName, + organization.remainingDays.toString(), + ); + } else if (organization.remainingDays === 1) { + return this.i18nService.t("freeTrialEndPromptForOneDay", organization.organizationName); + } else { + return this.i18nService.t("freeTrialEndPromptForLessThanADay", organization.organizationName); + } + } + + trackBy(index: number) { + return index; + } } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts index 3b7db72a09d..57eb9b1bdd9 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts @@ -1,5 +1,5 @@ import { Component, Inject, OnDestroy, OnInit } from "@angular/core"; -import { combineLatest, map, Observable, Subject, takeUntil } from "rxjs"; +import { combineLatest, map, Observable, of, Subject, switchMap, takeUntil } from "rxjs"; import { OrganizationUserApiService, @@ -8,11 +8,14 @@ import { import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -53,6 +56,8 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy { private resetPasswordService: OrganizationUserResetPasswordService, private userVerificationService: UserVerificationService, private toastService: ToastService, + private configService: ConfigService, + private organizationService: OrganizationService, ) {} async ngOnInit() { @@ -60,23 +65,39 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy { map((policies) => policies.filter((policy) => policy.type === PolicyType.ResetPassword)), ); + const managingOrg$ = this.configService + .getFeatureFlag$(FeatureFlag.AccountDeprovisioning) + .pipe( + switchMap((isAccountDeprovisioningEnabled) => + isAccountDeprovisioningEnabled + ? this.organizationService.organizations$.pipe( + map((organizations) => + organizations.find((o) => o.userIsManagedByOrganization === true), + ), + ) + : of(null), + ), + ); + combineLatest([ this.organization$, resetPasswordPolicies$, this.userDecryptionOptionsService.userDecryptionOptions$, + managingOrg$, ]) .pipe(takeUntil(this.destroy$)) - .subscribe(([organization, resetPasswordPolicies, decryptionOptions]) => { + .subscribe(([organization, resetPasswordPolicies, decryptionOptions, managingOrg]) => { this.organization = organization; this.resetPasswordPolicy = resetPasswordPolicies.find( (p) => p.organizationId === organization.id, ); - // A user can leave an organization if they are NOT using TDE and Key Connector, or they have a master password. + // A user can leave an organization if they are NOT a managed user and they are NOT using TDE and Key Connector, or they have a master password. this.showLeaveOrgOption = - (decryptionOptions.trustedDeviceOption == undefined && + managingOrg?.id !== organization.id && + ((decryptionOptions.trustedDeviceOption == undefined && decryptionOptions.keyConnectorOption == undefined) || - decryptionOptions.hasMasterPassword; + decryptionOptions.hasMasterPassword); // Hide the 3 dot menu if the user has no available actions this.hideMenu = diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts index c9066ae66d2..09a7356c452 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts @@ -1,12 +1,16 @@ -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; +import { Component, EventEmitter, inject, Input, OnDestroy, OnInit, Output } from "@angular/core"; +import { Router } from "@angular/router"; import { firstValueFrom, Subject } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { DialogService } from "@bitwarden/components"; import { VaultFilterService } from "../services/abstractions/vault-filter.service"; import { @@ -40,7 +44,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { isLoaded = false; protected destroy$: Subject = new Subject(); - + private router = inject(Router); get filtersList() { return this.filters ? Object.values(this.filters) : []; } @@ -85,6 +89,8 @@ export class VaultFilterComponent implements OnInit, OnDestroy { protected policyService: PolicyService, protected i18nService: I18nService, protected platformUtilsService: PlatformUtilsService, + protected billingApiService: BillingApiServiceAbstraction, + protected dialogService: DialogService, ) {} async ngOnInit(): Promise { @@ -111,6 +117,13 @@ export class VaultFilterComponent implements OnInit, OnDestroy { null, this.i18nService.t("disabledOrganizationFilterError"), ); + const metadata = await this.billingApiService.getOrganizationBillingMetadata(orgNode.node.id); + if (metadata.isSubscriptionUnpaid) { + const confirmed = await this.promptForPaymentNavigation(orgNode.node); + if (confirmed) { + await this.navigateToPaymentMethod(orgNode.node.id); + } + } return; } const filter = this.activeFilter; @@ -123,6 +136,32 @@ export class VaultFilterComponent implements OnInit, OnDestroy { await this.vaultFilterService.expandOrgFilter(); }; + private async promptForPaymentNavigation(org: Organization): Promise { + if (!org?.isOwner) { + await this.dialogService.openSimpleDialog({ + title: this.i18nService.t("suspendedOrganizationTitle", org?.name), + content: { key: "suspendedUserOrgMessage" }, + type: "danger", + acceptButtonText: this.i18nService.t("close"), + cancelButtonText: null, + }); + return false; + } + return await this.dialogService.openSimpleDialog({ + title: this.i18nService.t("suspendedOrganizationTitle", org?.name), + content: { key: "suspendedOwnerOrgMessage" }, + type: "danger", + acceptButtonText: this.i18nService.t("continue"), + cancelButtonText: this.i18nService.t("close"), + }); + } + + private async navigateToPaymentMethod(orgId: string) { + await this.router.navigate(["organizations", `${orgId}`, "billing", "payment-method"], { + state: { launchPaymentModalAutomatically: true }, + }); + } + applyTypeFilter = async (filterNode: TreeNode): Promise => { const filter = this.activeFilter; filter.resetFilter(); @@ -216,6 +255,12 @@ export class VaultFilterComponent implements OnInit, OnDestroy { type: CipherType.SecureNote, icon: "bwi-sticky-note", }, + { + id: "sshKey", + name: this.i18nService.t("typeSshKey"), + type: CipherType.SshKey, + icon: "bwi-key", + }, ]; const typeFilterSection: VaultFilterSection = { diff --git a/apps/web/src/app/vault/individual-vault/vault.component.html b/apps/web/src/app/vault/individual-vault/vault.component.html index b2c4fda57d0..679d2ce6f7e 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.html +++ b/apps/web/src/app/vault/individual-vault/vault.component.html @@ -1,4 +1,4 @@ - + ; private activeUserId: UserId; + protected organizationsPaymentStatus: FreeTrial[] = []; private searchText$ = new Subject(); private refresh$ = new BehaviorSubject(null); private destroy$ = new Subject(); private extensionRefreshEnabled: boolean; private vaultItemDialogRef?: DialogRef | undefined; + private readonly unpaidSubscriptionDialog$ = this.organizationService.organizations$.pipe( + filter((organizations) => organizations.length === 1), + switchMap(([organization]) => + from(this.billingApiService.getOrganizationBillingMetadata(organization.id)).pipe( + switchMap((organizationMetaData) => + from( + this.trialFlowService.handleUnpaidSubscriptionDialog( + organization, + organizationMetaData, + ), + ), + ), + ), + ), + ); constructor( private syncService: SyncService, @@ -211,6 +232,9 @@ export class VaultComponent implements OnInit, OnDestroy { private toastService: ToastService, private accountService: AccountService, private cipherFormConfigService: DefaultCipherFormConfigService, + private organizationApiService: OrganizationApiServiceAbstraction, + protected billingApiService: BillingApiServiceAbstraction, + private trialFlowService: TrialFlowService, ) {} async ngOnInit() { @@ -309,7 +333,6 @@ export class VaultComponent implements OnInit, OnDestroy { if (filter.collectionId === undefined || filter.collectionId === Unassigned) { return []; } - let collectionsToReturn = []; if (filter.organizationId !== undefined && filter.collectionId === All) { collectionsToReturn = collections @@ -362,7 +385,6 @@ export class VaultComponent implements OnInit, OnDestroy { filter(() => this.vaultItemDialogRef == undefined || !this.extensionRefreshEnabled), switchMap(async (params) => { const cipherId = getCipherIdFromParams(params); - if (cipherId) { if (await this.cipherService.get(cipherId)) { let action = params.action; @@ -393,6 +415,32 @@ export class VaultComponent implements OnInit, OnDestroy { ) .subscribe(); + this.unpaidSubscriptionDialog$.pipe(takeUntil(this.destroy$)).subscribe(); + + const organizationsPaymentStatus$ = this.organizationService.organizations$.pipe( + switchMap((allOrganizations) => { + return combineLatest( + allOrganizations + .filter((org) => org.isOwner) + .map((org) => + combineLatest([ + this.organizationApiService.getSubscription(org.id), + this.organizationApiService.getBilling(org.id), + ]).pipe( + map(([subscription, billing]) => { + return this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues( + org, + subscription, + billing?.paymentSource, + ); + }), + ), + ), + ); + }), + map((results) => results.filter((result) => result.shownBanner)), + ); + firstSetup$ .pipe( switchMap(() => this.refresh$), @@ -406,6 +454,7 @@ export class VaultComponent implements OnInit, OnDestroy { ciphers$, collections$, selectedCollection$, + organizationsPaymentStatus$, ]), ), takeUntil(this.destroy$), @@ -419,6 +468,7 @@ export class VaultComponent implements OnInit, OnDestroy { ciphers, collections, selectedCollection, + organizationsPaymentStatus, ]) => { this.filter = filter; this.canAccessPremium = canAccessPremium; @@ -434,7 +484,7 @@ export class VaultComponent implements OnInit, OnDestroy { this.showBulkMove = filter.type !== "trash"; this.isEmpty = collections?.length === 0 && ciphers?.length === 0; - + this.organizationsPaymentStatus = organizationsPaymentStatus; this.performingInitialLoad = false; this.refreshing = false; }, diff --git a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts index 02d280f5ff9..05c40fe2e79 100644 --- a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts +++ b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts @@ -1,14 +1,13 @@ import { TestBed } from "@angular/core/testing"; import { BehaviorSubject } from "rxjs"; -import { CollectionAdminService } from "@bitwarden/admin-console/common"; +import { CollectionAdminService, CollectionAdminView } from "@bitwarden/admin-console/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { CipherId } from "@bitwarden/common/types/guid"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { RoutedVaultFilterService } from "../../individual-vault/vault-filter/services/routed-vault-filter.service"; @@ -35,27 +34,41 @@ describe("AdminConsoleCipherFormConfigService", () => { status: OrganizationUserStatusType.Confirmed, }; const policyAppliesToActiveUser$ = new BehaviorSubject(true); + const collection = { + id: "12345-5555", + organizationId: "234534-34334", + name: "Test Collection 1", + assigned: false, + readOnly: true, + } as CollectionAdminView; + const collection2 = { + id: "12345-6666", + organizationId: "22222-2222", + name: "Test Collection 2", + assigned: true, + readOnly: false, + } as CollectionAdminView; + const organization$ = new BehaviorSubject(testOrg as Organization); const organizations$ = new BehaviorSubject([testOrg, testOrg2] as Organization[]); const getCipherAdmin = jest.fn().mockResolvedValue(null); - const getCipher = jest.fn().mockResolvedValue(null); beforeEach(async () => { getCipherAdmin.mockClear(); - getCipher.mockClear(); - getCipher.mockResolvedValue({ id: cipherId, name: "Test Cipher - (non-admin)" }); getCipherAdmin.mockResolvedValue({ id: cipherId, name: "Test Cipher - (admin)" }); await TestBed.configureTestingModule({ providers: [ AdminConsoleCipherFormConfigService, + { provide: OrganizationService, useValue: { get$: () => organization$, organizations$ } }, + { + provide: CollectionAdminService, + useValue: { getAll: () => Promise.resolve([collection, collection2]) }, + }, { provide: PolicyService, useValue: { policyAppliesToActiveUser$: () => policyAppliesToActiveUser$ }, }, - { provide: OrganizationService, useValue: { get$: () => organization$, organizations$ } }, - { provide: CipherService, useValue: { get: getCipher } }, - { provide: CollectionAdminService, useValue: { getAll: () => Promise.resolve([]) } }, { provide: RoutedVaultFilterService, useValue: { filter$: new BehaviorSubject({ organizationId: testOrg.id }) }, @@ -86,6 +99,12 @@ describe("AdminConsoleCipherFormConfigService", () => { expect(mode).toBe("edit"); }); + it("returns all collections", async () => { + const { collections } = await adminConsoleConfigService.buildConfig("edit", cipherId); + + expect(collections).toEqual([collection, collection2]); + }); + it("sets admin flag based on `canEditAllCiphers`", async () => { // Disable edit all ciphers on org testOrg.canEditAllCiphers = false; @@ -153,33 +172,14 @@ describe("AdminConsoleCipherFormConfigService", () => { expect(result.organizations).toEqual([testOrg, testOrg2]); }); - describe("getCipher", () => { - it("retrieves the cipher from the cipher service", async () => { - testOrg.canEditAllCiphers = false; + it("retrieves the cipher from the admin service", async () => { + getCipherAdmin.mockResolvedValue({ id: cipherId, name: "Test Cipher - (admin)" }); - adminConsoleConfigService = TestBed.inject(AdminConsoleCipherFormConfigService); + adminConsoleConfigService = TestBed.inject(AdminConsoleCipherFormConfigService); - const result = await adminConsoleConfigService.buildConfig("clone", cipherId); + await adminConsoleConfigService.buildConfig("add", cipherId); - expect(getCipher).toHaveBeenCalledWith(cipherId); - expect(result.originalCipher.name).toBe("Test Cipher - (non-admin)"); - - // Admin service not needed when cipher service can return the cipher - expect(getCipherAdmin).not.toHaveBeenCalled(); - }); - - it("retrieves the cipher from the admin service", async () => { - getCipher.mockResolvedValueOnce(null); - getCipherAdmin.mockResolvedValue({ id: cipherId, name: "Test Cipher - (admin)" }); - - adminConsoleConfigService = TestBed.inject(AdminConsoleCipherFormConfigService); - - await adminConsoleConfigService.buildConfig("add", cipherId); - - expect(getCipherAdmin).toHaveBeenCalledWith(cipherId); - - expect(getCipher).toHaveBeenCalledWith(cipherId); - }); + expect(getCipherAdmin).toHaveBeenCalledWith(cipherId); }); }); }); diff --git a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts index 328ab4475dc..457b4e83d03 100644 --- a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts +++ b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts @@ -6,9 +6,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType, OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { CipherId } from "@bitwarden/common/types/guid"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; @@ -25,7 +23,6 @@ import { RoutedVaultFilterService } from "../../individual-vault/vault-filter/se export class AdminConsoleCipherFormConfigService implements CipherFormConfigService { private policyService: PolicyService = inject(PolicyService); private organizationService: OrganizationService = inject(OrganizationService); - private cipherService: CipherService = inject(CipherService); private routedVaultFilterService: RoutedVaultFilterService = inject(RoutedVaultFilterService); private collectionAdminService: CollectionAdminService = inject(CollectionAdminService); private apiService: ApiService = inject(ApiService); @@ -51,20 +48,8 @@ export class AdminConsoleCipherFormConfigService implements CipherFormConfigServ map(([orgs, orgId]) => orgs.find((o) => o.id === orgId)), ); - private editableCollections$ = this.organization$.pipe( - switchMap(async (org) => { - if (!org) { - return []; - } - - const collections = await this.collectionAdminService.getAll(org.id); - // Users that can edit all ciphers can implicitly add to / edit within any collection - if (org.canEditAllCiphers) { - return collections; - } - // The user is only allowed to add/edit items to assigned collections that are not readonly - return collections.filter((c) => c.assigned && !c.readOnly); - }), + private allCollections$ = this.organization$.pipe( + switchMap(async (org) => await this.collectionAdminService.getAll(org.id)), ); async buildConfig( @@ -72,21 +57,17 @@ export class AdminConsoleCipherFormConfigService implements CipherFormConfigServ cipherId?: CipherId, cipherType?: CipherType, ): Promise { + const cipher = await this.getCipher(cipherId); const [organization, allowPersonalOwnership, allOrganizations, allCollections] = await firstValueFrom( combineLatest([ this.organization$, this.allowPersonalOwnership$, this.allOrganizations$, - this.editableCollections$, + this.allCollections$, ]), ); - const cipher = await this.getCipher(organization, cipherId); - - const collections = allCollections.filter( - (c) => c.organizationId === organization.id && c.assigned && !c.readOnly, - ); // When cloning from within the Admin Console, all organizations should be available. // Otherwise only the one in context should be const organizations = mode === "clone" ? allOrganizations : [organization]; @@ -100,7 +81,7 @@ export class AdminConsoleCipherFormConfigService implements CipherFormConfigServ admin: organization.canEditAllCiphers ?? false, allowPersonalOwnership: allowPersonalOwnershipOnlyForClone, originalCipher: cipher, - collections, + collections: allCollections, organizations, folders: [], // folders not applicable in the admin console hideIndividualVaultFields: true, @@ -108,19 +89,11 @@ export class AdminConsoleCipherFormConfigService implements CipherFormConfigServ }; } - private async getCipher(organization: Organization, id?: CipherId): Promise { + private async getCipher(id?: CipherId): Promise { if (id == null) { return Promise.resolve(null); } - // Check to see if the user has direct access to the cipher - const cipherFromCipherService = await this.cipherService.get(id); - - // If the organization doesn't allow admin/owners to edit all ciphers return the cipher - if (!organization.canEditAllCiphers && cipherFromCipherService != null) { - return cipherFromCipherService; - } - // Retrieve the cipher through the means of an admin const cipherResponse = await this.apiService.getCipherAdmin(id); cipherResponse.edit = true; diff --git a/apps/web/src/app/vault/org-vault/vault-filter/vault-filter.component.ts b/apps/web/src/app/vault/org-vault/vault-filter/vault-filter.component.ts index 8a3f25ab2c7..211d2346230 100644 --- a/apps/web/src/app/vault/org-vault/vault-filter/vault-filter.component.ts +++ b/apps/web/src/app/vault/org-vault/vault-filter/vault-filter.component.ts @@ -3,9 +3,11 @@ import { firstValueFrom, Subject } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { DialogService } from "@bitwarden/components"; import { VaultFilterComponent as BaseVaultFilterComponent } from "../../individual-vault/vault-filter/components/vault-filter.component"; //../../vault/vault-filter/components/vault-filter.component"; import { VaultFilterService } from "../../individual-vault/vault-filter/services/abstractions/vault-filter.service"; @@ -38,8 +40,17 @@ export class VaultFilterComponent protected policyService: PolicyService, protected i18nService: I18nService, protected platformUtilsService: PlatformUtilsService, + protected billingApiService: BillingApiServiceAbstraction, + protected dialogService: DialogService, ) { - super(vaultFilterService, policyService, i18nService, platformUtilsService); + super( + vaultFilterService, + policyService, + i18nService, + platformUtilsService, + billingApiService, + dialogService, + ); } async ngOnInit() { diff --git a/apps/web/src/app/vault/org-vault/vault.component.html b/apps/web/src/app/vault/org-vault/vault.component.html index 0bcdc52eaeb..9e9264e77cd 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.html +++ b/apps/web/src/app/vault/org-vault/vault.component.html @@ -1,3 +1,25 @@ + + + {{ freeTrial.message }} + + {{ "routeToPaymentMethodTrigger" | i18n }} + + + + ; + protected freeTrial$: Observable; /** * A list of collections that the user can assign items to and edit those items within. * @protected @@ -183,6 +197,21 @@ export class VaultComponent implements OnInit, OnDestroy { protected addAccessStatus$ = new BehaviorSubject(0); private extensionRefreshEnabled: boolean; private vaultItemDialogRef?: DialogRef | undefined; + private readonly unpaidSubscriptionDialog$ = this.organizationService.organizations$.pipe( + filter((organizations) => organizations.length === 1), + switchMap(([organization]) => + from(this.billingApiService.getOrganizationBillingMetadata(organization.id)).pipe( + switchMap((organizationMetaData) => + from( + this.trialFlowService.handleUnpaidSubscriptionDialog( + organization, + organizationMetaData, + ), + ), + ), + ), + ), + ); constructor( private route: ActivatedRoute, @@ -214,6 +243,9 @@ export class VaultComponent implements OnInit, OnDestroy { private toastService: ToastService, private configService: ConfigService, private cipherFormConfigService: CipherFormConfigService, + private organizationApiService: OrganizationApiServiceAbstraction, + private trialFlowService: TrialFlowService, + protected billingApiService: BillingApiServiceAbstraction, ) {} async ngOnInit() { @@ -546,6 +578,26 @@ export class VaultComponent implements OnInit, OnDestroy { ) .subscribe(); + this.unpaidSubscriptionDialog$.pipe(takeUntil(this.destroy$)).subscribe(); + + this.freeTrial$ = organization$.pipe( + filter((org) => org.isOwner), + switchMap((org) => + combineLatest([ + of(org), + this.organizationApiService.getSubscription(org.id), + this.organizationApiService.getBilling(org.id), + ]), + ), + map(([org, sub, billing]) => { + return this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues( + org, + sub, + billing?.paymentSource, + ); + }), + ); + firstSetup$ .pipe( switchMap(() => this.refresh$), @@ -596,6 +648,13 @@ export class VaultComponent implements OnInit, OnDestroy { ); } + async navigateToPaymentMethod() { + await this.router.navigate( + ["organizations", `${this.organization?.id}`, "billing", "payment-method"], + { state: { launchPaymentModalAutomatically: true } }, + ); + } + addAccessToggle(e: AddAccessStatusType) { this.addAccessStatus$.next(e); } diff --git a/apps/web/src/locales/af/messages.json b/apps/web/src/locales/af/messages.json index 3310b38ba6f..7837baaed5b 100644 --- a/apps/web/src/locales/af/messages.json +++ b/apps/web/src/locales/af/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/ar/messages.json b/apps/web/src/locales/ar/messages.json index 5698a2fb1b5..d3230826059 100644 --- a/apps/web/src/locales/ar/messages.json +++ b/apps/web/src/locales/ar/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/az/messages.json b/apps/web/src/locales/az/messages.json index 162954882f1..10f948b7a5b 100644 --- a/apps/web/src/locales/az/messages.json +++ b/apps/web/src/locales/az/messages.json @@ -5,14 +5,14 @@ "criticalApplications": { "message": "Kritik tətbiqlər" }, - "accessIntelligence": { - "message": "Müraciət Kəşfiyyatı" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Parol riski" }, "discoverAtRiskPasswords": { - "message": "Riskli parolları kəşf edin və bu parolları dəyişdirməsi üçün istifadəçiləri məlumatlandırın." + "message": "Discover at-risk passwords and notify users to change those passwords." }, "dataLastUpdated": { "message": "Datanın son güncəlləmə tarixi: $DATE$", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "1 dəvət haqqınız var." - }, "userUsingTwoStep": { "message": "Bu istifadəçinin hesabını qorumaq üçün iki addımlı giriş istifadə edilir." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Uyumlu olmayan üzvlər rədd ediləcək. Digər bütün təşkilatları tərk etdikdən sonra üzvlər, administratorlar tərəfindən bərpa edilə bilər." + }, + "deleteOrganizationUser": { + "message": "$NAME$ - sil", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "Bir hesab silindikdə, Bitwarden hesabı və onun fərdi seyf dataları həmişəlik silinir. Təşkilatdakı kolleksiya dataları qalır. Bunları yenidən fəallaşdırmaq üçün bir hesab yaradılmalı və yenidən təşkilata qoşulması lazımdır.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "$NAME$ silindi", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "İstifadəçi təşkilatdan çıxarıldı və əlaqələndirilmiş bütün istifadəçi dataları silindi." } } diff --git a/apps/web/src/locales/be/messages.json b/apps/web/src/locales/be/messages.json index ee0537a86c1..6268fe9c1db 100644 --- a/apps/web/src/locales/be/messages.json +++ b/apps/web/src/locales/be/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Гэты карыстальнік выкарыстоўвае двухэтапны ўваход для абароны свайго ўліковага запісу." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/bg/messages.json b/apps/web/src/locales/bg/messages.json index 3e48ade518c..fd236374cb9 100644 --- a/apps/web/src/locales/bg/messages.json +++ b/apps/web/src/locales/bg/messages.json @@ -5,14 +5,14 @@ "criticalApplications": { "message": "Важни приложения" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Рискова парола" }, "discoverAtRiskPasswords": { - "message": "Откриване на пароли в риск и известяване на потребителите да ги сменят." + "message": "Discover at-risk passwords and notify users to change those passwords." }, "dataLastUpdated": { "message": "Последно обновяване на данните: $DATE$", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "Имате 1 оставаща покана." - }, "userUsingTwoStep": { "message": "Този потребител използва двустепенна защита за достъп." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "На членовете, които не отговарят на това условие, ще бъдат отнети правомощията. Администраторите могат да възстановяват правомощията на членовете, след като те напуснат всички останали организации." + }, + "deleteOrganizationUser": { + "message": "Изтриване на $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "Когато даден член бъде изтрит, неговата регистрация в Битуорден, както и данните от трезора му, ще бъдат изтрити завинаги. Данните за колекции ще останат в организацията. Ако искате да го върнете, той трябва да си създаде нова регистрация и да бъде включен отново.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Изтрито: $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "Потребителят беше премахнат от организацията и всичките му данни бяха изтрити." } } diff --git a/apps/web/src/locales/bn/messages.json b/apps/web/src/locales/bn/messages.json index 6eff6b5deb8..934857a7d0f 100644 --- a/apps/web/src/locales/bn/messages.json +++ b/apps/web/src/locales/bn/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/bs/messages.json b/apps/web/src/locales/bs/messages.json index b5785942675..50d10c17db5 100644 --- a/apps/web/src/locales/bs/messages.json +++ b/apps/web/src/locales/bs/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/ca/messages.json b/apps/web/src/locales/ca/messages.json index ed831a39f8b..0974ed9b1a1 100644 --- a/apps/web/src/locales/ca/messages.json +++ b/apps/web/src/locales/ca/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Aquest usuari fa servir l'inici de sessió en dues passes per protegir el seu compte." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/cs/messages.json b/apps/web/src/locales/cs/messages.json index 7efd6d85959..c7a9b74c5db 100644 --- a/apps/web/src/locales/cs/messages.json +++ b/apps/web/src/locales/cs/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Kritické aplikace" }, - "accessIntelligence": { - "message": "Přístup k inteligenci" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Rizikové heslo" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "Zbývá Vám 1 pozvánka." - }, "userUsingTwoStep": { "message": "Tento uživatel používá pro ochranu svého účtu dvoufázové přihlášení." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Nevyhovujícím členům bude členství zrušeno. Správci mohou obnovit členy, jakmile opustí všechny ostatní organizace." + }, + "deleteOrganizationUser": { + "message": "Smazat $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "Pokud je člen smazán, jeho účet Bitwarden a individuální údaje z trezoru budou trvale smazány. Data kolekce zůstanou v organizaci. Pro jejich obnovení si musí vytvořit účet a být znovu zařazen do systému.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "$NAME$ - smazán", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "Uživatel byl odebrán z organizace a všechna přidružená uživatelská data byla smazána." } } diff --git a/apps/web/src/locales/cy/messages.json b/apps/web/src/locales/cy/messages.json index d8c96f419ab..4ca57a56bf2 100644 --- a/apps/web/src/locales/cy/messages.json +++ b/apps/web/src/locales/cy/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/da/messages.json b/apps/web/src/locales/da/messages.json index 4791cc3784a..25e85fef58b 100644 --- a/apps/web/src/locales/da/messages.json +++ b/apps/web/src/locales/da/messages.json @@ -5,14 +5,14 @@ "criticalApplications": { "message": "Kritiske applikationer" }, - "accessIntelligence": { - "message": "Adgangsintelligens" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Adgangskoderisiko" }, "discoverAtRiskPasswords": { - "message": "Opdag risikable adgangskoder og underret brugerne om at ændre disse." + "message": "Opdag udsatte adgangskoder og underret brugerne om at ændre disse." }, "dataLastUpdated": { "message": "Data senest opdateret: $DATE$", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "Der er 1 invitation tilbage." - }, "userUsingTwoStep": { "message": "Denne bruger benytter totrins-login for at beskytte kontoen." }, @@ -4689,7 +4686,7 @@ "message": "Begræns medlemmer i at tilmelde sig andre organisationer." }, "singleOrgPolicyDesc": { - "message": "Restrict members from joining other organizations. This policy is required for organizations that have enabled domain verification." + "message": "Begræns medlemmers deltagelse i andre organisationer. Denne politik kræves for organisationer med aktiveret domænebekræftelse." }, "singleOrgBlockCreateMessage": { "message": "Den nuværende organisationspolitik tillader dig ikke at deltage i mere end én organisation. Kontakt organisationens admins eller benyt en anden Bitwarden-konto under tilmelding." @@ -4698,7 +4695,7 @@ "message": "Organisationsmedlemmer, undtagen ejere eller admins, som allerede er medlem af en anden organisation, fjernes fra organisationen." }, "singleOrgPolicyMemberWarning": { - "message": "Non-compliant members will be placed in revoked status until they leave all other organizations. Administrators are exempt and can restore members once compliance is met." + "message": "Ikke-overholdende medlemmer placeres i privilegie-ophævet status, indtil de forlader alle øvrige organisationer. Administratorer er fritaget og kan gendanne medlemmer, når overholdelse er opfyldt." }, "requireSso": { "message": "Kræv single sign-on godkendelse" @@ -9553,9 +9550,35 @@ "message": "Selv-hosting" }, "verified-domain-single-org-warning": { - "message": "Verifying a domain will turn on the single organization policy." + "message": "Bekræftelse af et domæne vil slå den enkelte organisationspolitik til." }, "single-org-revoked-user-warning": { - "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + "message": "Ikke-overholdende medlemmers privilegier ophæves. Administratorer kan gendanne medlemmer, når overholdelse er opfyldt." + }, + "deleteOrganizationUser": { + "message": "Slet $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "Når medlemmer slettes, vil deres Bitwarden-konto og individuelle boksdata blive slettet permanent. Indsamlingsdata vil forblive i organisationen. For at genindsætte dem, skal de oprette en konto og onboarderes igen.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Slet $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "Brugeren er fjernet fra organisationen og alle tilknyttede brugerdata er slettet." } } diff --git a/apps/web/src/locales/de/messages.json b/apps/web/src/locales/de/messages.json index 0667b0d7fbd..01a34c0afb7 100644 --- a/apps/web/src/locales/de/messages.json +++ b/apps/web/src/locales/de/messages.json @@ -3,19 +3,19 @@ "message": "Alle Anwendungen" }, "criticalApplications": { - "message": "Critical applications" + "message": "Kritische Anwendungen" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { - "message": "Password Risk" + "message": "Passwort-Risiko" }, "discoverAtRiskPasswords": { "message": "Discover at-risk passwords and notify users to change those passwords." }, "dataLastUpdated": { - "message": "Data last updated: $DATE$", + "message": "Daten zuletzt aktualisiert: $DATE$", "placeholders": { "date": { "content": "$1", @@ -36,10 +36,10 @@ } }, "createNewLoginItem": { - "message": "Create new login item" + "message": "Neuen Zugangsdaten-Eintrag erstellen" }, "criticalApplicationsWithCount": { - "message": "Critical applications ($COUNT$)", + "message": "Kritische Anwendungen ($COUNT$)", "placeholders": { "count": { "content": "$1", @@ -57,7 +57,7 @@ } }, "noAppsInOrgTitle": { - "message": "No applications found in $ORG NAME$", + "message": "Keine Anwendungen in $ORG NAME$ gefunden", "placeholders": { "org name": { "content": "$1", @@ -66,22 +66,22 @@ } }, "noAppsInOrgDescription": { - "message": "As users save logins, applications appear here, showing any at-risk passwords. Mark critical apps and notify users to update passwords." + "message": "Während Benutzer Zugangsdaten speichern, werden hier Anwendungen angezeigt, die alle gefährdeten Passwörter anzeigen. Markiere kritische Anwendungen und benachrichtige Benutzer, um Passwörter zu ändern." }, "noCriticalAppsTitle": { - "message": "You haven't marked any applications as a Critical" + "message": "Du hast keine Anwendungen als kritisch markiert" }, "noCriticalAppsDescription": { - "message": "Select your most critical applications to discover at-risk passwords, and notify users to change those passwords." + "message": "Wähle deine wichtigsten Anwendungen aus, um gefährdete Passwörter zu ermitteln, und benachrichtige die Benutzer, damit sie diese Passwörter ändern." }, "markCriticalApps": { - "message": "Mark critical apps" + "message": "Kritische Anwendungen markieren" }, "markAppAsCritical": { - "message": "Mark app as critical" + "message": "Anwendung als kritisch markieren" }, "appsMarkedAsCritical": { - "message": "Apps marked as critical" + "message": "Als kritisch markierte Anwendungen" }, "application": { "message": "Anwendung" @@ -90,13 +90,13 @@ "message": "Risikoreiche Passwörter" }, "requestPasswordChange": { - "message": "Request password change" + "message": "Passwortänderung anfordern" }, "totalPasswords": { "message": "Passwörter insgesamt" }, "searchApps": { - "message": "Search applications" + "message": "Anwendungen suchen" }, "atRiskMembers": { "message": "Risikoreiche Mitglieder" @@ -1738,7 +1738,7 @@ "message": "Vorsicht, diese Aktionen sind nicht umkehrbar!" }, "dangerZoneDescSingular": { - "message": "Careful, this action is not reversible!" + "message": "Vorsicht, diese Aktion ist nicht mehr rückgängig zu machen!" }, "deauthorizeSessions": { "message": "Sitzungen abmelden" @@ -1753,7 +1753,7 @@ "message": "Alle Sitzungen wurden abgemeldet" }, "accountIsManagedMessage": { - "message": "This account is managed by $ORGANIZATIONNAME$", + "message": "Dieses Konto wird von $ORGANIZATIONNAME$ verwaltet", "placeholders": { "organizationName": { "content": "$1", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Dieser Benutzer hat sein Konto mit einer Zwei-Faktor-Authentifizierung geschützt." }, @@ -4689,7 +4686,7 @@ "message": "Benutzern verbieten, anderen Organisationen beizutreten." }, "singleOrgPolicyDesc": { - "message": "Restrict members from joining other organizations. This policy is required for organizations that have enabled domain verification." + "message": "Verbiete Mitgliedern den Beitritt zu anderen Organisationen. Diese Richtlinie ist für Organisationen erforderlich, die die Domain-Verifizierung aktiviert haben." }, "singleOrgBlockCreateMessage": { "message": "Deine aktuelle Organisation hat eine Richtlinie, die es dir nicht erlaubt, mehr als einer Organisation beizutreten. Bitte kontaktiere die Administratoren deiner Organisation oder melden dich mit einem anderen Bitwarden-Konto an." @@ -4698,7 +4695,7 @@ "message": "Organisationsmitglieder, die nicht Eigentümer oder Administratoren sind und bereits Mitglied einer anderen Organisation sind, werden aus deiner Organisation entfernt." }, "singleOrgPolicyMemberWarning": { - "message": "Non-compliant members will be placed in revoked status until they leave all other organizations. Administrators are exempt and can restore members once compliance is met." + "message": "Nicht-konforme Mitglieder werden in den Widerrufen-Status versetzt, bis sie alle anderen Organisationen verlassen. Administratoren sind ausgenommen und können Mitglieder wieder aufnehmen, sobald die Anforderungen erfüllt sind." }, "requireSso": { "message": "Single Sign-on-Authentifizierung erfordern" @@ -5274,7 +5271,7 @@ "message": "Öffne deine Organisations-" }, "usingTheMenuSelect": { - "message": "Using the menu, select" + "message": "Wählen Sie das Menü aus" }, "toGrantAccessToSelectedMembers": { "message": "um Zugriff auf ausgewählte Mitglieder zu gewähren." @@ -6417,7 +6414,7 @@ "message": "E-Mail generieren" }, "generatorBoundariesHint": { - "message": "Value must be between $MIN$ and $MAX$", + "message": "Wert muss zwischen $MIN$ und $MAX$ liegen", "description": "Explains spin box minimum and maximum values to the user", "placeholders": { "min": { @@ -9072,7 +9069,7 @@ "message": "Geheimnis anzeigen" }, "noClients": { - "message": "There are no clients to list" + "message": "Keine Ereignisse vorhanden." }, "providerBillingEmailHint": { "message": "Diese E-Mail-Adresse wird alle Rechnungen erhalten, die diesen Anbieter betreffen", @@ -9187,7 +9184,7 @@ "description": "The status of an invoice." }, "clientDetails": { - "message": "Client details" + "message": "Kundendetails" }, "downloadCSV": { "message": "CSV-Datei herunterladen" @@ -9333,7 +9330,7 @@ "message": "Text-Sends" }, "includesXMembers": { - "message": "for $COUNT$ member", + "message": "für $COUNT$ Mitglied", "placeholders": { "count": { "content": "$1", @@ -9543,7 +9540,7 @@ "message": "Bist du sicher, dass du diesen Anhang dauerhaft löschen möchtest?" }, "manageSubscriptionFromThe": { - "message": "Manage subscription from the", + "message": "Abonnement verwalten von der", "description": "This represents the beginning of a sentence. The full sentence will be 'Manage subscription from the Provider Portal', but 'Provider Portal' will be a link and thus cannot be included in the translation file." }, "toHostBitwardenOnYourOwnServer": { @@ -9553,9 +9550,35 @@ "message": "Selbst gehostet" }, "verified-domain-single-org-warning": { - "message": "Verifying a domain will turn on the single organization policy." + "message": "Die Domain-Verifizierung aktiviert die Richtlinie für einzelne Organisationen." }, "single-org-revoked-user-warning": { - "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + "message": "Nicht konforme Mitglieder werden gesperrt. Administratoren können Mitglieder wieder aufnehmen, sobald sie alle anderen Organisationen verlassen." + }, + "deleteOrganizationUser": { + "message": "$NAME$ gelöscht", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "Wenn ein Mitglied gelöscht wird, werden sein Bitwarden-Konto und persönlichen Tresor-Daten dauerhaft gelöscht. Sammlungs-Daten bleiben in der Organisation. Um sie wiederherzustellen, müssen diese ein Konto erstellen und den Onboarding-Prozess erneut durchlaufen.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "$NAME$ gelöscht", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "Der Benutzer wurde aus der Organisation entfernt und alle zugehörigen Benutzerdaten wurden gelöscht." } } diff --git a/apps/web/src/locales/el/messages.json b/apps/web/src/locales/el/messages.json index 30f1945600b..eeb9cc14acb 100644 --- a/apps/web/src/locales/el/messages.json +++ b/apps/web/src/locales/el/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Αυτός ο χρήστης χρησιμοποιεί τρόπο σύνδεσης δύο βημάτων για να προστατεύσει το λογαριασμό του." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 05332032e0a..00d2102c786 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -559,6 +559,9 @@ "typeSecureNote": { "message": "Secure note" }, + "typeSshKey": { + "message": "SSH key" + }, "typeLoginPlural": { "message": "Logins" }, @@ -3834,6 +3837,55 @@ "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, + "freeTrialEndPrompt": { + "message": "Your free trial ends in $COUNT$ days. To maintain your subscription,", + "placeholders": { + "count": { + "content": "$1", + "example": "You must set up 2FA on your user account before you can join this organization." + } + } + }, + "freeTrialEndPromptAboveTwoDays": { + "message": "$ORGANIZATION$, your free trial ends in $COUNT$ days. To maintain your subscription,", + "placeholders": { + "count": { + "content": "$2", + "example": "organization name" + }, + "organization": { + "content": "$1", + "example": "remaining days" + } + } + }, + "freeTrialEndPromptForOneDay": { + "message": "$ORGANIZATION$, your free trial ends tomorrow. To maintain your subscription,", + "placeholders": { + "organization": { + "content": "$1", + "example": "organization name" + } + } + }, + "freeTrialEndPromptForOneDayNoOrgName": { + "message": "Your free trial ends tomorrow. To maintain your subscription," + }, + "freeTrialEndPromptForLessThanADay": { + "message": "$ORGANIZATION$, your free trial ends today. To maintain your subscription,", + "placeholders": { + "organization": { + "content": "$1", + "example": "organization name" + } + } + }, + "freeTrialEndingSoonWithoutOrgName": { + "message": "Your free trial ends today. To maintain your subscription," + }, + "routeToPaymentMethodTrigger": { + "message": "add a payment method." + }, "joinOrganization": { "message": "Join organization" }, @@ -8441,7 +8493,7 @@ }, "addAPaymentMethod": { "message": "add a payment method", - "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'To maintain your subscription for $ORG$, add a payment method.'" + "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'To maintain your subscription for $ORG$, add a payment method'" }, "organizationInformation": { "message": "Organization information" @@ -9014,6 +9066,12 @@ "providerPlan": { "message": "Managed Service Provider" }, + "managedServiceProvider": { + "message": "Managed service provider" + }, + "multiOrganizationEnterprise": { + "message": "Multi-organization enterprise" + }, "orgSeats": { "message": "Organization Seats" }, @@ -9416,6 +9474,30 @@ "additionalStorageGbMessage": { "message": "GB additional storage" }, + "sshKeyAlgorithm": { + "message": "Key algorithm" + }, + "sshKeyFingerprint": { + "message": "Fingerprint" + }, + "sshKeyPrivateKey": { + "message": "Private key" + }, + "sshKeyPublicKey": { + "message": "Public key" + }, + "sshKeyAlgorithmED25519": { + "message": "ED25519" + }, + "sshKeyAlgorithmRSA2048": { + "message": "RSA 2048-Bit" + }, + "sshKeyAlgorithmRSA3072": { + "message": "RSA 3072-Bit" + }, + "sshKeyAlgorithmRSA4096": { + "message": "RSA 4096-Bit" + }, "premiumAccounts": { "message": "6 premium accounts" }, @@ -9580,5 +9662,38 @@ }, "organizationUserDeletedDesc": { "message": "The user was removed from the organization and all associated user data has been deleted." + }, + "deletedUserId": { + "message": "Deleted user $ID$ - an owner / admin deleted the user account", + "placeholders": { + "id": { + "content": "$1", + "example": "First 8 Character of a GUID" + } + } + }, + "userLeftOrganization": { + "message": "User $ID$ left organization", + "placeholders": { + "id": { + "content": "$1", + "example": "First 8 Character of a GUID" + } + } + }, + "suspendedOrganizationTitle": { + "message": "The $ORGANIZATION$ is suspended", + "placeholders": { + "organization": { + "content": "$1", + "example": "Acme c" + } + } + }, + "suspendedUserOrgMessage": { + "message": "Contact your organization owner for assistance." + }, + "suspendedOwnerOrgMessage": { + "message": "To regain access to your organization, add a payment method." } } diff --git a/apps/web/src/locales/en_GB/messages.json b/apps/web/src/locales/en_GB/messages.json index 431eeeeebcd..7fac11d847c 100644 --- a/apps/web/src/locales/en_GB/messages.json +++ b/apps/web/src/locales/en_GB/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organisations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organisation. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organisation and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/en_IN/messages.json b/apps/web/src/locales/en_IN/messages.json index 0e16ab3bd6e..96d0e5403bf 100644 --- a/apps/web/src/locales/en_IN/messages.json +++ b/apps/web/src/locales/en_IN/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organisations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organisation. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organisation and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/eo/messages.json b/apps/web/src/locales/eo/messages.json index 55bcef6cb15..ccc026e2120 100644 --- a/apps/web/src/locales/eo/messages.json +++ b/apps/web/src/locales/eo/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Ĉi tiu uzanto uzas du-paŝan ensaluton por protekti sian konton." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/es/messages.json b/apps/web/src/locales/es/messages.json index 20fe6ea933e..c053473ac9d 100644 --- a/apps/web/src/locales/es/messages.json +++ b/apps/web/src/locales/es/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Este usuario está usando autenticación de dos pasos para proteger su cuenta." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/et/messages.json b/apps/web/src/locales/et/messages.json index fff2bb8cd5d..6ba79ebe6c1 100644 --- a/apps/web/src/locales/et/messages.json +++ b/apps/web/src/locales/et/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Sellel kasutajal on kaheastmeline kinnitamine sisse lülitatud." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/eu/messages.json b/apps/web/src/locales/eu/messages.json index ac7ea7b93af..9f67ef96101 100644 --- a/apps/web/src/locales/eu/messages.json +++ b/apps/web/src/locales/eu/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Erabiltzaile hau bi urratseko saio hasiera erabiltzen ari da bere kontua babesteko." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/fa/messages.json b/apps/web/src/locales/fa/messages.json index f92ddcd9035..e4903ab83a3 100644 --- a/apps/web/src/locales/fa/messages.json +++ b/apps/web/src/locales/fa/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "این کاربر از ورود دو مرحله ای برای محافظت از حساب خود استفاده میکند." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/fi/messages.json b/apps/web/src/locales/fi/messages.json index 02238935f35..69d53ee762d 100644 --- a/apps/web/src/locales/fi/messages.json +++ b/apps/web/src/locales/fi/messages.json @@ -5,14 +5,14 @@ "criticalApplications": { "message": "Kriittiset sovellukset" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Salasanariski" }, "discoverAtRiskPasswords": { - "message": "Havaitse vaarantuneet salasanat ja ilmoita käyttäjille niiden vaihdosta." + "message": "Discover at-risk passwords and notify users to change those passwords." }, "dataLastUpdated": { "message": "Tiedot päivitetty viimeksi: $DATE$", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "Sinulla on yksi kutsu jäljellä." - }, "userUsingTwoStep": { "message": "Käyttäjä on suojannut tilinsä kaksivaiheisella kirjautumisella." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/fil/messages.json b/apps/web/src/locales/fil/messages.json index 119381538b4..7b880a85b52 100644 --- a/apps/web/src/locales/fil/messages.json +++ b/apps/web/src/locales/fil/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Gumagamit ang user na ito ng dalawang hakbang na pag login upang maprotektahan ang kanilang account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/fr/messages.json b/apps/web/src/locales/fr/messages.json index f1a1ffbf4a4..270c93b2c5f 100644 --- a/apps/web/src/locales/fr/messages.json +++ b/apps/web/src/locales/fr/messages.json @@ -5,14 +5,14 @@ "criticalApplications": { "message": "Applications critiques" }, - "accessIntelligence": { - "message": "Accéder à l'Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Risque du mot de passe" }, "discoverAtRiskPasswords": { - "message": "Découvrez les mots de passe à risque et avertissez les utilisateurs de modifier ces mots de passe." + "message": "Discover at-risk passwords and notify users to change those passwords." }, "dataLastUpdated": { "message": "Dernière mise à jour des données : $DATE$", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "Il vous reste 1 invitation." - }, "userUsingTwoStep": { "message": "Cet utilisateur utilise l'authentification à deux facteurs pour protéger son compte." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Les membres non conformes seront révoqués. Les administrateurs peuvent restaurer les membres une fois qu'ils quittent toutes les autres organisations." + }, + "deleteOrganizationUser": { + "message": "Supprimer $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "Lorsqu'un membre est supprimé, son compte Bitwarden et les données individuelles du coffre seront définitivement supprimées. Les données de Collection resteront dans l'organisation. Pour les rétablir, ils doivent créer un compte et être intégrés à nouveau.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Supprimer $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "L'utilisateur a été supprimé de l'organisation et toutes les données utilisateur associées ont été supprimées." } } diff --git a/apps/web/src/locales/gl/messages.json b/apps/web/src/locales/gl/messages.json index dda5e85f4cb..b5a1650478f 100644 --- a/apps/web/src/locales/gl/messages.json +++ b/apps/web/src/locales/gl/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/he/messages.json b/apps/web/src/locales/he/messages.json index 768e877cd2e..930db2948e4 100644 --- a/apps/web/src/locales/he/messages.json +++ b/apps/web/src/locales/he/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "משתמש זה הפעיל כניסה דו שלבית כדי להגן על חשבונו." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/hi/messages.json b/apps/web/src/locales/hi/messages.json index a20f1ec13bf..d815051acc1 100644 --- a/apps/web/src/locales/hi/messages.json +++ b/apps/web/src/locales/hi/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/hr/messages.json b/apps/web/src/locales/hr/messages.json index ef64895f899..d45357dc517 100644 --- a/apps/web/src/locales/hr/messages.json +++ b/apps/web/src/locales/hr/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Ovaj korisnik upotrebljava prijavu u dva koraka za zaštitu svog računa." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/hu/messages.json b/apps/web/src/locales/hu/messages.json index 0ee0f1c5370..95ecaecc6b4 100644 --- a/apps/web/src/locales/hu/messages.json +++ b/apps/web/src/locales/hu/messages.json @@ -5,14 +5,14 @@ "criticalApplications": { "message": "Kritikus alkalmazások" }, - "accessIntelligence": { - "message": "Elérés intelligencia" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Jelszó kockázat" }, "discoverAtRiskPasswords": { - "message": "Fedezzük fel a veszélyeztetett jelszavakat és értesítsük a felhasználókat, hogy módosítsák ezeket a jelszavakat." + "message": "Discover at-risk passwords and notify users to change those passwords." }, "dataLastUpdated": { "message": "Az adatok utolsó frissítése: $DATE$", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "1 meghívó maradt." - }, "userUsingTwoStep": { "message": "Ez a felhasználó kétlépcsős bejelentkezést használ fiókja védelmére." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "A nem megfelelő tagok visszavonásra kerülnek. Az adminisztrátorok visszaállíthatják a tagokat, miután elhagyják az összes többi szervezetet." + }, + "deleteOrganizationUser": { + "message": "$NAME$ törlése", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "Amikor egy tag törlésre kerül, a Bitwarden fiókjuk és az egyéni széf adataik véglegesen törlésre kerülnek. A gyűjtési adatok a szervezetben maradnak. A visszaállításukhoz egy fiókot kell létrehozni és újra be kell lépni.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "$NAME$ törlésre került.", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "A felhasználó eltávolításra került a szervezetből és az összes kapcsolódó felhasználói adat törlésre került." } } diff --git a/apps/web/src/locales/id/messages.json b/apps/web/src/locales/id/messages.json index f29e7780f73..0a9b35b4b12 100644 --- a/apps/web/src/locales/id/messages.json +++ b/apps/web/src/locales/id/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Pengguna ini menggunakan proses masuk dua langkah untuk melindungi akun mereka." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/it/messages.json b/apps/web/src/locales/it/messages.json index 906e3e41078..5f7a48bf3a8 100644 --- a/apps/web/src/locales/it/messages.json +++ b/apps/web/src/locales/it/messages.json @@ -5,14 +5,14 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Intelligence sugli accessi" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Rischio password" }, "discoverAtRiskPasswords": { - "message": "Scopri le password a rischio e richiedi agli utenti di cambiarle." + "message": "Discover at-risk passwords and notify users to change those passwords." }, "dataLastUpdated": { "message": "Ultimo aggiornamento: $DATE$", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Questo utente usa la verifica in due passaggi per proteggere il suo account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/ja/messages.json b/apps/web/src/locales/ja/messages.json index c9bd116eb69..979c22eb99d 100644 --- a/apps/web/src/locales/ja/messages.json +++ b/apps/web/src/locales/ja/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "インテリジェンスへのアクセス" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "このユーザーはアカウントを保護するため二段階認証を利用しています。" }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/ka/messages.json b/apps/web/src/locales/ka/messages.json index af3f8f4edd1..3f837d2745a 100644 --- a/apps/web/src/locales/ka/messages.json +++ b/apps/web/src/locales/ka/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/km/messages.json b/apps/web/src/locales/km/messages.json index 99e5f7bcf32..57cc9a404e1 100644 --- a/apps/web/src/locales/km/messages.json +++ b/apps/web/src/locales/km/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/kn/messages.json b/apps/web/src/locales/kn/messages.json index b3df003e79b..867ad7c2624 100644 --- a/apps/web/src/locales/kn/messages.json +++ b/apps/web/src/locales/kn/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "ಈ ಬಳಕೆದಾರರು ತಮ್ಮ ಖಾತೆಯನ್ನು ರಕ್ಷಿಸಲು ಎರಡು-ಹಂತದ ಲಾಗಿನ್ ಅನ್ನು ಬಳಸುತ್ತಿದ್ದಾರೆ." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/ko/messages.json b/apps/web/src/locales/ko/messages.json index 2be44401f9e..8b7474784a5 100644 --- a/apps/web/src/locales/ko/messages.json +++ b/apps/web/src/locales/ko/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "이 사용자는 계정을 보호하기 위해 2단계 로그인을 사용하고 있습니다." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/lv/messages.json b/apps/web/src/locales/lv/messages.json index 07f92f54970..0ee268beac9 100644 --- a/apps/web/src/locales/lv/messages.json +++ b/apps/web/src/locales/lv/messages.json @@ -5,14 +5,14 @@ "criticalApplications": { "message": "Kritiskas lietotnes" }, - "accessIntelligence": { - "message": "Piekļuves inteliģence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Paroļu risks" }, "discoverAtRiskPasswords": { - "message": "Atklāj riskam pakļautas paroles un apziņo lietotājus, lai tās nomaina!" + "message": "Discover at-risk passwords and notify users to change those passwords." }, "dataLastUpdated": { "message": "Dati pēdējoreiz atjaunināti: $DATE$", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "Tev ir atlicis 1 uzaicinājums." - }, "userUsingTwoStep": { "message": "Šis lietotājs izmanto divpakāpju pieteikšanos, lai aizsargātu savu kontu." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Pamatnostādnei neatbilstošie dalībnieki tiks atsaukti. Pārvaldītāji var atjaunot dalībniekus, tiklīdz viņi pametīs visas pārējās apvienības." + }, + "deleteOrganizationUser": { + "message": "Izdzēst $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "Kad dalībnieks tiek izdzēsts, viņa Bitwarden konts un personīgās glabātavas dati tiks neatgriezeniski izdzēsti. Krājumu dati paliks apvienībām. Lai atjaunotu dalībniekus, viņiem atkārtoti jāizveido konts un jāpievieno komandai.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Izdzēsts/a $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "Lietotājs tika noņemts no apvienības, un visa saistītā lietotāja informācija tika izdzēsta." } } diff --git a/apps/web/src/locales/ml/messages.json b/apps/web/src/locales/ml/messages.json index 0a2ec292d8a..7a71b4b07c9 100644 --- a/apps/web/src/locales/ml/messages.json +++ b/apps/web/src/locales/ml/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "ഈ ഉപയോക്താവ് അവരുടെ അക്കൗണ്ട് രണ്ട്-പ്രവേശനം ഉപയോഗിച്ച് സുരക്ഷിതമാക്കിയിരിക്കുന്നു." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/mr/messages.json b/apps/web/src/locales/mr/messages.json index 99e5f7bcf32..57cc9a404e1 100644 --- a/apps/web/src/locales/mr/messages.json +++ b/apps/web/src/locales/mr/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/my/messages.json b/apps/web/src/locales/my/messages.json index 99e5f7bcf32..57cc9a404e1 100644 --- a/apps/web/src/locales/my/messages.json +++ b/apps/web/src/locales/my/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/nb/messages.json b/apps/web/src/locales/nb/messages.json index b4baaa41e12..59e0334fde4 100644 --- a/apps/web/src/locales/nb/messages.json +++ b/apps/web/src/locales/nb/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Denne brukeren bruker 2-trinnsinnlogging til å beskytte kontoen sin." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/ne/messages.json b/apps/web/src/locales/ne/messages.json index 828b477d70b..5bcb5fb6c24 100644 --- a/apps/web/src/locales/ne/messages.json +++ b/apps/web/src/locales/ne/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/nl/messages.json b/apps/web/src/locales/nl/messages.json index 6ed2bf68a7b..7595736573c 100644 --- a/apps/web/src/locales/nl/messages.json +++ b/apps/web/src/locales/nl/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Belangrijke applicaties" }, - "accessIntelligence": { - "message": "Toegangsintelligentie" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Wachtwoordrisico" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "Je hebt 1 uitnodiging over." - }, "userUsingTwoStep": { "message": "Het account van deze gebruiker is beschermd met tweestapsaanmelding." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Niet-conforme leden worden ingetrokken. Beheerders kunnen leden herstellen zodra ze alle andere organisaties verlaten." + }, + "deleteOrganizationUser": { + "message": "$NAME$ verwijderen", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "Als je een lid verwijdert, verwijder je permanent hun Bitwarden-account en individuele kluisgegevens. Collectiegegevens blijven in de organisatie. Om het account te heractiveren, moet het lid een account aanmaken en opnieuw on-boarding doorlopen.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "$NAME$ verwijderd", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "De gebruiker is verwijderd uit de organisatie en alle bijbehorende gebruikersgegevens zijn verwijderd." } } diff --git a/apps/web/src/locales/nn/messages.json b/apps/web/src/locales/nn/messages.json index 1611c91135d..7bbd7e75bf5 100644 --- a/apps/web/src/locales/nn/messages.json +++ b/apps/web/src/locales/nn/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/or/messages.json b/apps/web/src/locales/or/messages.json index 99e5f7bcf32..57cc9a404e1 100644 --- a/apps/web/src/locales/or/messages.json +++ b/apps/web/src/locales/or/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/pl/messages.json b/apps/web/src/locales/pl/messages.json index 2ca3fd14060..13b870d4706 100644 --- a/apps/web/src/locales/pl/messages.json +++ b/apps/web/src/locales/pl/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Ten użytkownik korzysta z logowania dwustopniowego, aby chronić swoje konto." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/pt_BR/messages.json b/apps/web/src/locales/pt_BR/messages.json index 75b86c3720f..5984d3472e4 100644 --- a/apps/web/src/locales/pt_BR/messages.json +++ b/apps/web/src/locales/pt_BR/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Acessar a Inteligência" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Este usuário está usando o login em duas etapas para proteger a sua conta." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/pt_PT/messages.json b/apps/web/src/locales/pt_PT/messages.json index ddde15005b2..a335b157978 100644 --- a/apps/web/src/locales/pt_PT/messages.json +++ b/apps/web/src/locales/pt_PT/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Aplicações críticas" }, - "accessIntelligence": { - "message": "Acesso à informação" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Risco da palavra-passe" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "Ainda tem 1 convite." - }, "userUsingTwoStep": { "message": "Este utilizador está a utilizar a verificação de dois passos para proteger a sua conta." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Os membros não conformes serão revogados. Os administradores podem restaurar os membros quando estes saírem de todas as outras organizações." + }, + "deleteOrganizationUser": { + "message": "Eliminar $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "Quando um membro é eliminado, a sua conta Bitwarden e os dados individuais do cofre serão permanentemente eliminados. Os dados da coleção permanecerão na organização. Para os reintegrar, têm de criar uma conta e ser novamente integrados.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "$NAME$ eliminado", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "O utilizador foi removido da organização e todos os dados de utilizador associados foram eliminados." } } diff --git a/apps/web/src/locales/ro/messages.json b/apps/web/src/locales/ro/messages.json index d925bf1003f..8f57c764c5e 100644 --- a/apps/web/src/locales/ro/messages.json +++ b/apps/web/src/locales/ro/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Acest utilizator folosește conectarea în două etape pentru a-și proteja contul." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/ru/messages.json b/apps/web/src/locales/ru/messages.json index ef6c6607778..21928393746 100644 --- a/apps/web/src/locales/ru/messages.json +++ b/apps/web/src/locales/ru/messages.json @@ -5,14 +5,14 @@ "criticalApplications": { "message": "Критичные приложения" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Риск пароля" }, "discoverAtRiskPasswords": { - "message": "Обнаружение паролей, подверженных риску, и уведомление пользователей о необходимости сменить эти пароли." + "message": "Discover at-risk passwords and notify users to change those passwords." }, "dataLastUpdated": { "message": "Последнее обновление: $DATE$", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "У вас осталось 1 приглашение." - }, "userUsingTwoStep": { "message": "Этот пользователь использует двухэтапную аутентификацию для защиты своего аккаунта." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Участники, не соблюдающие требования, будут аннулированы. Администраторы могут восстановить участников, как только они покинут все другие организации." + }, + "deleteOrganizationUser": { + "message": "Удалить $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "При удалении участника его аккаунт Bitwarden и личные данные хранилища будут удалены навсегда. Данные коллекций останутся в организации. Чтобы восстановить их, необходимо создать аккаунт и заново пройти процедуру регистрации.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Аккаунт $NAME$ удален", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "Пользователь был удален из организации, и все связанные с ним данные были удалены." } } diff --git a/apps/web/src/locales/si/messages.json b/apps/web/src/locales/si/messages.json index cd1f0c408d8..76be2326ec0 100644 --- a/apps/web/src/locales/si/messages.json +++ b/apps/web/src/locales/si/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/sk/messages.json b/apps/web/src/locales/sk/messages.json index 8d7de3775d8..e353e2e4605 100644 --- a/apps/web/src/locales/sk/messages.json +++ b/apps/web/src/locales/sk/messages.json @@ -5,14 +5,14 @@ "criticalApplications": { "message": "Kritické aplikácie" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Ohrozenie hesla" }, "discoverAtRiskPasswords": { - "message": "Odhaľte ohrozené hesla a upozornite používateľov, aby si ich zmenili." + "message": "Discover at-risk passwords and notify users to change those passwords." }, "dataLastUpdated": { "message": "Posledná aktualizácia dát: $DATE$", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "Ostáva vám 1 pozvánka." - }, "userUsingTwoStep": { "message": "Tento používateľ používa dvojstupňové overovanie aby si zabezpečil konto." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Členovia, ktorí nedodržiavajú pravidlo, budú odvolaní. Správcovia môžu obnoviť členov po ich odchode zo všetkých ostatných organizácií." + }, + "deleteOrganizationUser": { + "message": "Odstrániť $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "Keď je člen odstránený, jeho účet Bitwarden a individuálne údaje z trezora sa natrvalo odstránia. Údaje zo zbierky zostanú v organizácii. Ak ho chcete znovu pridať, musí si vytvoriť účet a byť znovu zaradený do systému.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Odstránený $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "Používateľ bol odstránený z organizácie a všetky súvisiace dáta boli vymazané." } } diff --git a/apps/web/src/locales/sl/messages.json b/apps/web/src/locales/sl/messages.json index d827f4cec4c..eafa7de2bfe 100644 --- a/apps/web/src/locales/sl/messages.json +++ b/apps/web/src/locales/sl/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/sr/messages.json b/apps/web/src/locales/sr/messages.json index 78f298b9406..9a568d445e2 100644 --- a/apps/web/src/locales/sr/messages.json +++ b/apps/web/src/locales/sr/messages.json @@ -5,14 +5,14 @@ "criticalApplications": { "message": "Критичне апликације" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Ризик од лозинке" }, "discoverAtRiskPasswords": { - "message": "Откријте ризичне лозинке и обавестите кориснике да промене те лозинке." + "message": "Discover at-risk passwords and notify users to change those passwords." }, "dataLastUpdated": { "message": "Подаци су последњи пут ажурирани: $DATE$", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "Имате још 1 позивницу." - }, "userUsingTwoStep": { "message": "Овај корисник користи пријаву у два корака за заштиту свог налога." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Чланови који не испуњавају услове биће опозвани. Администратори могу вратити чланове када напусте све друге организације." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/sr_CS/messages.json b/apps/web/src/locales/sr_CS/messages.json index fd22d2d8cf4..7c46cf4275f 100644 --- a/apps/web/src/locales/sr_CS/messages.json +++ b/apps/web/src/locales/sr_CS/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/sv/messages.json b/apps/web/src/locales/sv/messages.json index 8eacf15fb31..ecf9a0a5e4f 100644 --- a/apps/web/src/locales/sv/messages.json +++ b/apps/web/src/locales/sv/messages.json @@ -1,18 +1,18 @@ { "allApplications": { - "message": "All applications" + "message": "Alla applikationer" }, "criticalApplications": { "message": "Kritiska applikationer" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { - "message": "Password Risk" + "message": "Lösenordsrisk" }, "discoverAtRiskPasswords": { - "message": "Discover at-risk passwords and notify users to change those passwords." + "message": "Upptäck sårbara lösenord och meddela användarna att de ska byta lösenord." }, "dataLastUpdated": { "message": "Data last updated: $DATE$", @@ -27,7 +27,7 @@ "message": "Notified members" }, "allApplicationsWithCount": { - "message": "All applications ($COUNT$)", + "message": "Alla applikationer ($COUNT$)", "placeholders": { "count": { "content": "$1", @@ -81,10 +81,10 @@ "message": "Markera app som kritisk" }, "appsMarkedAsCritical": { - "message": "Apps marked as critical" + "message": "Appar markerade som kritiska" }, "application": { - "message": "Application" + "message": "Applikation" }, "atRiskPasswords": { "message": "At-risk passwords" @@ -163,7 +163,7 @@ "message": "Personal details" }, "identification": { - "message": "Identification" + "message": "Identifikation" }, "contactInfo": { "message": "Contact info" @@ -181,7 +181,7 @@ } }, "itemHistory": { - "message": "Item history" + "message": "Objekthistorik" }, "authenticatorKey": { "message": "Autentiseringsnyckel" @@ -759,7 +759,7 @@ "message": "Kopiera adress" }, "copyPhone": { - "message": "Copy phone" + "message": "Kopiera telefon" }, "copyEmail": { "message": "Copy email" @@ -994,7 +994,7 @@ "message": "Logga in med nyckel" }, "useSingleSignOn": { - "message": "Use single sign-on" + "message": "Använd Single Sign-On" }, "welcomeBack": { "message": "Välkommen tillbaka" @@ -1370,7 +1370,7 @@ "message": "Yubico OTP-säkerhetsnyckel" }, "yubiKeyDesc": { - "message": "Använd en YubiKey för att komma åt ditt konto. Fungerar med YubiKey 4-serien, 5-serien och NEO-enheter." + "message": "Använd en YubiKey 4-, 5- eller NEO-enhet." }, "duoDescV2": { "message": "Ange en kod som genererats av Duo Security.", @@ -1390,7 +1390,7 @@ "message": "Nyckel" }, "webAuthnDesc": { - "message": "Använd en WebAuthn-aktiverad säkerhetsnyckel för att komma åt ditt konto." + "message": "Använd enhetens biometri eller en FIDO2-kompatibel säkerhetsnyckel." }, "webAuthnMigrated": { "message": "(Migrerad från FIDO)" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "Du har 1 inbjudan kvar." - }, "userUsingTwoStep": { "message": "Denna användare använder tvåstegsverifiering för att skydda sitt konto." }, @@ -5733,7 +5730,7 @@ } }, "vaultTimeoutPolicyInEffect1": { - "message": "$HOURS$ hour(s) and $MINUTES$ minute(s) maximum.", + "message": "$HOURS$ timme(ar) och $MINUTES$ minut(er) max.", "placeholders": { "hours": { "content": "$1", @@ -6536,7 +6533,7 @@ "message": "Generera ett e-postalias med en extern vidarebefordranstjänst." }, "forwarderDomainName": { - "message": "Email domain", + "message": "E-postdomän", "description": "Labels the domain name email forwarder service option" }, "forwarderDomainNameHint": { @@ -6708,10 +6705,10 @@ "description": "the text, 'SCIM' and 'API', are acronyms and should not be translated." }, "rotateKey": { - "message": "Rotate key" + "message": "Rotera nyckel" }, "scimApiKey": { - "message": "SCIM API key", + "message": "SCIM API-nyckel", "description": "the text, 'SCIM' and 'API', are acronyms and should not be translated." }, "copyScimUrl": { @@ -6824,7 +6821,7 @@ "message": "Duo two-step login is required for your account." }, "launchDuo": { - "message": "Launch Duo" + "message": "Starta Duo" }, "turnOn": { "message": "Turn on" @@ -9303,19 +9300,19 @@ "message": "Impact of rotating your encryption key" }, "learnMoreAboutEncryptionAlgorithms": { - "message": "Learn more about encryption algorithms" + "message": "Läs mer om krypteringsalgoritmer" }, "learnMoreAboutKDFIterations": { "message": "Learn more about KDF iterations" }, "learnMoreAboutLocalization": { - "message": "Learn more about localization" + "message": "Läs mer om lokalisering" }, "learnMoreAboutWebsiteIcons": { "message": "Learn more about using website icons" }, "learnMoreAboutUserAccess": { - "message": "Learn more about user access" + "message": "Läs mer om användaråtkomst" }, "learnMoreAboutMemberRoles": { "message": "Learn more about member roles and permissions" @@ -9438,7 +9435,7 @@ "message": "Directory integration" }, "passwordLessSso": { - "message": "Passwordless SSO" + "message": "PasswordLess SSO" }, "accountRecovery": { "message": "Kontoåterställning" @@ -9487,10 +9484,10 @@ "message": "Redigera åtkomst" }, "textHelpText": { - "message": "Use text fields for data like security questions" + "message": "Använd textfält för data, som t. ex. säkerhetsfrågor" }, "hiddenHelpText": { - "message": "Use hidden fields for sensitive data like a password" + "message": "Använd dolda fält för känslig data, som t. ex. ett lösenord" }, "checkBoxHelpText": { "message": "Use checkboxes if you'd like to autofill a form's checkbox, like a remember email" @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Radera $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/te/messages.json b/apps/web/src/locales/te/messages.json index 99e5f7bcf32..57cc9a404e1 100644 --- a/apps/web/src/locales/te/messages.json +++ b/apps/web/src/locales/te/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/th/messages.json b/apps/web/src/locales/th/messages.json index e5f976ffe15..8710f029f4e 100644 --- a/apps/web/src/locales/th/messages.json +++ b/apps/web/src/locales/th/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/tr/messages.json b/apps/web/src/locales/tr/messages.json index 5650fb5e082..45bbc83fa35 100644 --- a/apps/web/src/locales/tr/messages.json +++ b/apps/web/src/locales/tr/messages.json @@ -5,14 +5,14 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Parola Riski" }, "discoverAtRiskPasswords": { - "message": "Riskli parolaları tespit edip kullanıcıları bu parolaları değiştirmeleri konusunda bilgilendirin." + "message": "Discover at-risk passwords and notify users to change those passwords." }, "dataLastUpdated": { "message": "Son veri güncellemesi: $DATE$", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "1 davetiyeniz kaldı." - }, "userUsingTwoStep": { "message": "Bu kullanıcı hesabını korumak için iki aşamalı giriş kullanıyor." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/uk/messages.json b/apps/web/src/locales/uk/messages.json index c6aad6ceb28..624fb132a81 100644 --- a/apps/web/src/locales/uk/messages.json +++ b/apps/web/src/locales/uk/messages.json @@ -5,14 +5,14 @@ "criticalApplications": { "message": "Критичні програми" }, - "accessIntelligence": { - "message": "Керування доступом" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Ризиковані паролі" }, "discoverAtRiskPasswords": { - "message": "Дізнавайтеся про ризиковані паролі та сповіщайте користувачів про необхідність їх заміни." + "message": "Discover at-risk passwords and notify users to change those passwords." }, "dataLastUpdated": { "message": "Дані востаннє оновлено: $DATE$", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "У вас залишилось 1 запрошення." - }, "userUsingTwoStep": { "message": "Цей користувач використовує двоетапну перевірку для захисту свого облікового запису." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Невідповідних учасників буде відкликано. Адміністратори зможуть відновити учасників, коли ті покинуть всі інші організації." + }, + "deleteOrganizationUser": { + "message": "Видалити $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "Якщо учасник видаляється, його обліковий запис Bitwarden разом з даними особистого сховища також остаточно видаляється. Дані збірок залишаються в організації. Щоб їх відновити, учасник повинен створити обліковий запис і приєднатися до організації знову.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Видалено $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "Користувача вилучено з організації. Всі пов'язані дані користувача видалено." } } diff --git a/apps/web/src/locales/vi/messages.json b/apps/web/src/locales/vi/messages.json index 83e54637b79..a19cd508793 100644 --- a/apps/web/src/locales/vi/messages.json +++ b/apps/web/src/locales/vi/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/zh_CN/messages.json b/apps/web/src/locales/zh_CN/messages.json index 56fddc0db41..360ca2920f9 100644 --- a/apps/web/src/locales/zh_CN/messages.json +++ b/apps/web/src/locales/zh_CN/messages.json @@ -5,14 +5,14 @@ "criticalApplications": { "message": "关键应用程序" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "密码风险" }, "discoverAtRiskPasswords": { - "message": "发现有风险的密码并通知用户更改这些密码。" + "message": "Discover at-risk passwords and notify users to change those passwords." }, "dataLastUpdated": { "message": "数据最后更新于:$DATE$", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "您还剩下 1 个邀请。" - }, "userUsingTwoStep": { "message": "此用户正在使用两步登录来保护他们的账户。" }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "不符合要求的成员将被撤销。管理员可以在他们离开所有其他组织后恢复其成员资格。" + }, + "deleteOrganizationUser": { + "message": "删除 $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "当成员被删除时,他们的 Bitwarden 账户和个人密码库数据将被永久删除。集合数据将保留在组织中。要恢复它们,他们必须创建一个账户并重新加入。", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "已删除 $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "该用户已从组织中删除,所有关联的用户数据已被删除。" } } diff --git a/apps/web/src/locales/zh_TW/messages.json b/apps/web/src/locales/zh_TW/messages.json index 571116f4bc1..7673ad3be30 100644 --- a/apps/web/src/locales/zh_TW/messages.json +++ b/apps/web/src/locales/zh_TW/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "此使用者正在使用兩步驟登入保護帳戶。" }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.html b/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.html index ed58650f211..e72b9ed661a 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.html @@ -12,7 +12,7 @@ }} - + plan.type === PlanType.TeamsMonthly); - const enterprisePlan = this.dialogParams.plans.find( - (plan) => plan.type === PlanType.EnterpriseMonthly, - ); - this.discountPercentage = response.discountPercentage; const discountFactor = this.discountPercentage ? (100 - this.discountPercentage) / 100 : 1; - this.planCards = [ - { - name: this.i18nService.t("planNameTeams"), - cost: teamsPlan.PasswordManager.providerPortalSeatPrice * discountFactor, - type: teamsPlan.type, - plan: teamsPlan, - selected: true, - }, - { - name: this.i18nService.t("planNameEnterprise"), - cost: enterprisePlan.PasswordManager.providerPortalSeatPrice * discountFactor, - type: enterprisePlan.type, - plan: enterprisePlan, - selected: false, - }, - ]; + this.planCards = []; + + for (let i = 0; i < this.providerPlans.length; i++) { + const providerPlan = this.providerPlans[i]; + const plan = this.dialogParams.plans.find((plan) => plan.type === providerPlan.type); + + let planName: string; + switch (plan.productTier) { + case ProductTierType.Teams: { + planName = this.i18nService.t("planNameTeams"); + break; + } + case ProductTierType.Enterprise: { + planName = this.i18nService.t("planNameEnterprise"); + break; + } + } + + this.planCards.push({ + name: planName, + cost: plan.PasswordManager.providerPortalSeatPrice * discountFactor, + type: plan.type, + plan: plan, + selected: i === 0, + }); + } this.loading = false; } diff --git a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription-status.component.html b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription-status.component.html index 6c4bf422f7a..f08dbf0c37a 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription-status.component.html +++ b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription-status.component.html @@ -4,7 +4,7 @@ {{ "billingPlan" | i18n }} - {{ "providerPlan" | i18n }} + {{ plan | i18n }} {{ data.status.label }} diff --git a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription-status.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription-status.component.ts index c3ad875136e..dea7d4ca197 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription-status.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription-status.component.ts @@ -1,6 +1,7 @@ import { DatePipe } from "@angular/common"; import { Component, Input } from "@angular/core"; +import { ProviderType } from "@bitwarden/common/admin-console/enums"; import { ProviderSubscriptionResponse } from "@bitwarden/common/billing/models/response/provider-subscription-response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -32,6 +33,15 @@ export class ProviderSubscriptionStatusComponent { private i18nService: I18nService, ) {} + get plan(): string { + switch (this.subscription.providerType) { + case ProviderType.Msp: + return "managedServiceProvider"; + case ProviderType.MultiOrganizationEnterprise: + return "multiOrganizationEnterprise"; + } + } + get status(): string { if (this.subscription.cancelAt && this.subscription.status === "active") { return "pending_cancellation"; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html index a82e35afb60..31746e7601c 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html @@ -1,3 +1,24 @@ + + + {{ freeTrial.message }} + + {{ "routeToPaymentMethodTrigger" | i18n }} + + + 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 7073b4c289f..bf2dbb76ad3 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 @@ -1,5 +1,5 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; -import { ActivatedRoute } from "@angular/router"; +import { ActivatedRoute, Router } from "@angular/router"; import { map, Observable, @@ -12,14 +12,20 @@ import { take, share, firstValueFrom, - concatMap, + of, + filter, } from "rxjs"; +import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService } from "@bitwarden/components"; +import { TrialFlowService } from "@bitwarden/web-vault/app/billing/services/trial-flow.service"; +import { FreeTrial } from "@bitwarden/web-vault/app/core/types/free-trial"; import { OrganizationCounts } from "../models/view/counts.view"; import { ProjectListView } from "../models/view/project-list.view"; @@ -81,6 +87,8 @@ export class OverviewComponent implements OnInit, OnDestroy { protected showOnboarding = false; protected loading = true; protected organizationEnabled = false; + protected organization: Organization; + protected i18n: I18nPipe; protected onboardingTasks$: Observable; protected view$: Observable<{ @@ -91,6 +99,7 @@ export class OverviewComponent implements OnInit, OnDestroy { tasks: OrganizationTasks; counts: OrganizationCounts; }>; + protected freeTrial$: Observable; constructor( private route: ActivatedRoute, @@ -104,6 +113,10 @@ export class OverviewComponent implements OnInit, OnDestroy { private i18nService: I18nService, private smOnboardingTasksService: SMOnboardingTasksService, private logService: LogService, + private router: Router, + + private organizationApiService: OrganizationApiServiceAbstraction, + private trialFlowService: TrialFlowService, ) {} ngOnInit() { @@ -114,18 +127,35 @@ export class OverviewComponent implements OnInit, OnDestroy { distinctUntilChanged(), ); - orgId$ - .pipe( - concatMap(async (orgId) => await this.organizationService.get(orgId)), - takeUntil(this.destroy$), - ) - .subscribe((org) => { - this.organizationId = org.id; - this.organizationName = org.name; - this.userIsAdmin = org.isAdmin; - this.loading = true; - this.organizationEnabled = org.enabled; - }); + const org$ = orgId$.pipe(switchMap((orgId) => this.organizationService.get(orgId))); + + org$.pipe(takeUntil(this.destroy$)).subscribe((org) => { + this.organizationId = org.id; + this.organization = org; + this.organizationName = org.name; + this.userIsAdmin = org.isAdmin; + this.loading = true; + this.organizationEnabled = org.enabled; + }); + + this.freeTrial$ = org$.pipe( + filter((org) => org.isOwner), + switchMap((org) => + combineLatest([ + of(org), + this.organizationApiService.getSubscription(org.id), + this.organizationApiService.getBilling(org.id), + ]), + ), + map(([org, sub, billing]) => { + return this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues( + org, + sub, + billing?.paymentSource, + ); + }), + takeUntil(this.destroy$), + ); const projects$ = combineLatest([ orgId$, @@ -197,6 +227,15 @@ export class OverviewComponent implements OnInit, OnDestroy { }); } + async navigateToPaymentMethod() { + await this.router.navigate( + ["organizations", `${this.organizationId}`, "billing", "payment-method"], + { + state: { launchPaymentModalAutomatically: true }, + }, + ); + } + ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.module.ts index 72039f532ae..b9c09a0d671 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.module.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.module.ts @@ -1,5 +1,7 @@ import { NgModule } from "@angular/core"; +import { BannerModule } from "@bitwarden/components"; + import { OnboardingModule } from "../../../../../../apps/web/src/app/shared/components/onboarding/onboarding.module"; import { SecretsManagerSharedModule } from "../shared/sm-shared.module"; @@ -8,7 +10,7 @@ import { OverviewComponent } from "./overview.component"; import { SectionComponent } from "./section.component"; @NgModule({ - imports: [SecretsManagerSharedModule, OverviewRoutingModule, OnboardingModule], + imports: [SecretsManagerSharedModule, OverviewRoutingModule, OnboardingModule, BannerModule], declarations: [OverviewComponent, SectionComponent], providers: [], }) diff --git a/libs/angular/src/vault/components/add-edit.component.ts b/libs/angular/src/vault/components/add-edit.component.ts index aa661236f4c..952f2071e91 100644 --- a/libs/angular/src/vault/components/add-edit.component.ts +++ b/libs/angular/src/vault/components/add-edit.component.ts @@ -14,7 +14,8 @@ import { OrganizationUserStatusType, PolicyType } from "@bitwarden/common/admin- import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils"; -import { EventType } from "@bitwarden/common/enums"; +import { ClientType, EventType } from "@bitwarden/common/enums"; +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"; @@ -36,6 +37,7 @@ import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view" import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view"; +import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService } from "@bitwarden/components"; import { PasswordRepromptService } from "@bitwarden/vault"; @@ -71,6 +73,7 @@ export class AddEditComponent implements OnInit, OnDestroy { restorePromise: Promise; checkPasswordPromise: Promise; showPassword = false; + showPrivateKey = false; showTotpSeed = false; showCardNumber = false; showCardCode = false; @@ -134,6 +137,7 @@ export class AddEditComponent implements OnInit, OnDestroy { { name: i18nService.t("typeIdentity"), value: CipherType.Identity }, { name: i18nService.t("typeSecureNote"), value: CipherType.SecureNote }, ]; + this.cardBrandOptions = [ { name: "-- " + i18nService.t("select") + " --", value: null }, { name: "Visa", value: "Visa" }, @@ -200,6 +204,11 @@ export class AddEditComponent implements OnInit, OnDestroy { this.writeableCollections = await this.loadCollections(); this.canUseReprompt = await this.passwordRepromptService.enabled(); + + const sshKeysEnabled = await this.configService.getFeatureFlag(FeatureFlag.SSHKeyVaultItem); + if (this.platformUtilsService.getClientType() == ClientType.Desktop && sshKeysEnabled) { + this.typeOptions.push({ name: this.i18nService.t("typeSshKey"), value: CipherType.SshKey }); + } } ngOnDestroy() { @@ -279,6 +288,7 @@ export class AddEditComponent implements OnInit, OnDestroy { this.cipher.identity = new IdentityView(); this.cipher.secureNote = new SecureNoteView(); this.cipher.secureNote.type = SecureNoteType.Generic; + this.cipher.sshKey = new SshKeyView(); this.cipher.reprompt = CipherRepromptType.None; } } @@ -601,6 +611,10 @@ export class AddEditComponent implements OnInit, OnDestroy { } } + togglePrivateKey() { + this.showPrivateKey = !this.showPrivateKey; + } + toggleUriOptions(uri: LoginUriView) { const u = uri as any; u.showOptions = u.showOptions == null && uri.match != null ? false : !u.showOptions; diff --git a/libs/angular/src/vault/components/view.component.ts b/libs/angular/src/vault/components/view.component.ts index c2666056705..3226e1292bb 100644 --- a/libs/angular/src/vault/components/view.component.ts +++ b/libs/angular/src/vault/components/view.component.ts @@ -60,6 +60,7 @@ export class ViewComponent implements OnDestroy, OnInit { showPasswordCount: boolean; showCardNumber: boolean; showCardCode: boolean; + showPrivateKey: boolean; canAccessPremium: boolean; showPremiumRequiredTotp: boolean; totpCode: string; @@ -325,6 +326,10 @@ export class ViewComponent implements OnDestroy, OnInit { } } + togglePrivateKey() { + this.showPrivateKey = !this.showPrivateKey; + } + async checkPassword() { if ( this.cipher.login == null || diff --git a/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts index e66fc0cf12a..051275f7945 100644 --- a/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts @@ -6,6 +6,7 @@ import { SecretVerificationRequest } from "../../../auth/models/request/secret-v import { ApiKeyResponse } from "../../../auth/models/response/api-key.response"; import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response"; import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request"; +import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request"; import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request"; import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request"; import { PaymentRequest } from "../../../billing/models/request/payment.request"; @@ -40,6 +41,9 @@ export class OrganizationApiServiceAbstraction { getLicense: (id: string, installationId: string) => Promise; getAutoEnrollStatus: (identifier: string) => Promise; create: (request: OrganizationCreateRequest) => Promise; + createWithoutPayment: ( + request: OrganizationNoPaymentMethodCreateRequest, + ) => Promise; createLicense: (data: FormData) => Promise; save: (id: string, request: OrganizationUpdateRequest) => Promise; updatePayment: (id: string, request: PaymentRequest) => Promise; diff --git a/libs/common/src/admin-console/enums/provider-type.enum.ts b/libs/common/src/admin-console/enums/provider-type.enum.ts index 5f81c338f0e..d802c659f6f 100644 --- a/libs/common/src/admin-console/enums/provider-type.enum.ts +++ b/libs/common/src/admin-console/enums/provider-type.enum.ts @@ -1,4 +1,5 @@ export enum ProviderType { Msp = 0, Reseller = 1, + MultiOrganizationEnterprise = 2, } diff --git a/libs/common/src/admin-console/models/request/organization-create.request.ts b/libs/common/src/admin-console/models/request/organization-create.request.ts index 9f0441c4340..98f19bebaf4 100644 --- a/libs/common/src/admin-console/models/request/organization-create.request.ts +++ b/libs/common/src/admin-console/models/request/organization-create.request.ts @@ -1,32 +1,7 @@ -import { PaymentMethodType, PlanType } from "../../../billing/enums"; -import { InitiationPath } from "../../../models/request/reference-event.request"; +import { PaymentMethodType } from "../../../billing/enums"; +import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request"; -import { OrganizationKeysRequest } from "./organization-keys.request"; - -export class OrganizationCreateRequest { - name: string; - businessName: string; - billingEmail: string; - planType: PlanType; - key: string; - keys: OrganizationKeysRequest; +export class OrganizationCreateRequest extends OrganizationNoPaymentMethodCreateRequest { paymentMethodType: PaymentMethodType; paymentToken: string; - additionalSeats: number; - maxAutoscaleSeats: number; - additionalStorageGb: number; - premiumAccessAddon: boolean; - collectionName: string; - taxIdNumber: string; - billingAddressLine1: string; - billingAddressLine2: string; - billingAddressCity: string; - billingAddressState: string; - billingAddressPostalCode: string; - billingAddressCountry: string; - useSecretsManager: boolean; - additionalSmSeats: number; - additionalServiceAccounts: number; - isFromSecretsManagerTrial: boolean; - initiationPath: InitiationPath; } diff --git a/libs/common/src/admin-console/services/organization/organization-api.service.ts b/libs/common/src/admin-console/services/organization/organization-api.service.ts index 2ff4f2321a3..a2259d73cc5 100644 --- a/libs/common/src/admin-console/services/organization/organization-api.service.ts +++ b/libs/common/src/admin-console/services/organization/organization-api.service.ts @@ -7,6 +7,7 @@ import { SecretVerificationRequest } from "../../../auth/models/request/secret-v import { ApiKeyResponse } from "../../../auth/models/response/api-key.response"; import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response"; import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request"; +import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request"; import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request"; import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request"; import { PaymentRequest } from "../../../billing/models/request/payment.request"; @@ -107,6 +108,21 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction return new OrganizationResponse(r); } + async createWithoutPayment( + request: OrganizationNoPaymentMethodCreateRequest, + ): Promise { + const r = await this.apiService.send( + "POST", + "/organizations/create-without-payment", + request, + true, + true, + ); + // Forcing a sync will notify organization service that they need to repull + await this.syncService.fullSync(true); + return new OrganizationResponse(r); + } + async createLicense(data: FormData): Promise { const r = await this.apiService.send( "POST", diff --git a/libs/common/src/billing/abstractions/organization-billing.service.ts b/libs/common/src/billing/abstractions/organization-billing.service.ts index d19724b600a..72902baa30e 100644 --- a/libs/common/src/billing/abstractions/organization-billing.service.ts +++ b/libs/common/src/billing/abstractions/organization-billing.service.ts @@ -44,4 +44,8 @@ export abstract class OrganizationBillingServiceAbstraction { purchaseSubscription: (subscription: SubscriptionInformation) => Promise; startFree: (subscription: SubscriptionInformation) => Promise; + + purchaseSubscriptionNoPaymentMethod: ( + subscription: SubscriptionInformation, + ) => Promise; } diff --git a/libs/common/src/billing/models/request/organization-no-payment-method-create-request.ts b/libs/common/src/billing/models/request/organization-no-payment-method-create-request.ts new file mode 100644 index 00000000000..b48caec8dfc --- /dev/null +++ b/libs/common/src/billing/models/request/organization-no-payment-method-create-request.ts @@ -0,0 +1,29 @@ +import { OrganizationKeysRequest } from "../../../admin-console/models/request/organization-keys.request"; +import { InitiationPath } from "../../../models/request/reference-event.request"; +import { PlanType } from "../../enums"; + +export class OrganizationNoPaymentMethodCreateRequest { + name: string; + businessName: string; + billingEmail: string; + planType: PlanType; + key: string; + keys: OrganizationKeysRequest; + additionalSeats: number; + maxAutoscaleSeats: number; + additionalStorageGb: number; + premiumAccessAddon: boolean; + collectionName: string; + taxIdNumber: string; + billingAddressLine1: string; + billingAddressLine2: string; + billingAddressCity: string; + billingAddressState: string; + billingAddressPostalCode: string; + billingAddressCountry: string; + useSecretsManager: boolean; + additionalSmSeats: number; + additionalServiceAccounts: number; + isFromSecretsManagerTrial: boolean; + initiationPath: InitiationPath; +} diff --git a/libs/common/src/billing/models/response/organization-billing-metadata.response.ts b/libs/common/src/billing/models/response/organization-billing-metadata.response.ts index 3d846e6c987..ae6d1ac92c1 100644 --- a/libs/common/src/billing/models/response/organization-billing-metadata.response.ts +++ b/libs/common/src/billing/models/response/organization-billing-metadata.response.ts @@ -4,11 +4,13 @@ export class OrganizationBillingMetadataResponse extends BaseResponse { isEligibleForSelfHost: boolean; isManaged: boolean; isOnSecretsManagerStandalone: boolean; + isSubscriptionUnpaid: boolean; constructor(response: any) { super(response); this.isEligibleForSelfHost = this.getResponseProperty("IsEligibleForSelfHost"); this.isManaged = this.getResponseProperty("IsManaged"); this.isOnSecretsManagerStandalone = this.getResponseProperty("IsOnSecretsManagerStandalone"); + this.isSubscriptionUnpaid = this.getResponseProperty("IsSubscriptionUnpaid"); } } diff --git a/libs/common/src/billing/models/response/provider-subscription-response.ts b/libs/common/src/billing/models/response/provider-subscription-response.ts index 2dc9d4281de..2ecf988addd 100644 --- a/libs/common/src/billing/models/response/provider-subscription-response.ts +++ b/libs/common/src/billing/models/response/provider-subscription-response.ts @@ -1,3 +1,5 @@ +import { ProviderType } from "@bitwarden/common/admin-console/enums"; +import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; import { SubscriptionSuspensionResponse } from "@bitwarden/common/billing/models/response/subscription-suspension.response"; import { TaxInfoResponse } from "@bitwarden/common/billing/models/response/tax-info.response"; @@ -13,6 +15,7 @@ export class ProviderSubscriptionResponse extends BaseResponse { taxInformation?: TaxInfoResponse; cancelAt?: string; suspension?: SubscriptionSuspensionResponse; + providerType: ProviderType; constructor(response: any) { super(response); @@ -34,6 +37,7 @@ export class ProviderSubscriptionResponse extends BaseResponse { if (suspension != null) { this.suspension = new SubscriptionSuspensionResponse(suspension); } + this.providerType = this.getResponseProperty("providerType"); } } @@ -44,6 +48,8 @@ export class ProviderPlanResponse extends BaseResponse { purchasedSeats: number; cost: number; cadence: string; + type: PlanType; + productTier: ProductTierType; constructor(response: any) { super(response); @@ -53,5 +59,7 @@ export class ProviderPlanResponse extends BaseResponse { this.purchasedSeats = this.getResponseProperty("PurchasedSeats"); this.cost = this.getResponseProperty("Cost"); this.cadence = this.getResponseProperty("Cadence"); + this.type = this.getResponseProperty("Type"); + this.productTier = this.getResponseProperty("ProductTier"); } } diff --git a/libs/common/src/billing/services/organization-billing.service.ts b/libs/common/src/billing/services/organization-billing.service.ts index eebea0ca74e..efc36278532 100644 --- a/libs/common/src/billing/services/organization-billing.service.ts +++ b/libs/common/src/billing/services/organization-billing.service.ts @@ -17,6 +17,7 @@ import { SubscriptionInformation, } from "../abstractions/organization-billing.service"; import { PlanType } from "../enums"; +import { OrganizationNoPaymentMethodCreateRequest } from "../models/request/organization-no-payment-method-create-request"; interface OrganizationKeys { encryptedKey: EncString; @@ -77,6 +78,28 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs return response; } + async purchaseSubscriptionNoPaymentMethod( + subscription: SubscriptionInformation, + ): Promise { + const request = new OrganizationNoPaymentMethodCreateRequest(); + + const organizationKeys = await this.makeOrganizationKeys(); + + this.setOrganizationKeys(request, organizationKeys); + + this.setOrganizationInformation(request, subscription.organization); + + this.setPlanInformation(request, subscription.plan); + + const response = await this.organizationApiService.createWithoutPayment(request); + + await this.apiService.refreshIdentityToken(); + + await this.syncService.fullSync(true); + + return response; + } + private async makeOrganizationKeys(): Promise { const [encryptedKey, key] = await this.keyService.makeOrgKey(); const [publicKey, encryptedPrivateKey] = await this.keyService.makeKeyPair(key); @@ -106,7 +129,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs } private setOrganizationInformation( - request: OrganizationCreateRequest, + request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest, information: OrganizationInformation, ): void { request.name = information.name; @@ -115,7 +138,10 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs request.initiationPath = information.initiationPath; } - private setOrganizationKeys(request: OrganizationCreateRequest, keys: OrganizationKeys): void { + private setOrganizationKeys( + request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest, + keys: OrganizationKeys, + ): void { request.key = keys.encryptedKey.encryptedString; request.keys = new OrganizationKeysRequest( keys.publicKey, @@ -146,7 +172,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs } private setPlanInformation( - request: OrganizationCreateRequest, + request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest, information: PlanInformation, ): void { request.planType = information.type; diff --git a/libs/common/src/enums/event-type.enum.ts b/libs/common/src/enums/event-type.enum.ts index c72fb80de4d..51b324bb434 100644 --- a/libs/common/src/enums/event-type.enum.ts +++ b/libs/common/src/enums/event-type.enum.ts @@ -56,6 +56,8 @@ export enum EventType { OrganizationUser_Restored = 1512, OrganizationUser_ApprovedAuthRequest = 1513, OrganizationUser_RejectedAuthRequest = 1514, + OrganizationUser_Deleted = 1515, + OrganizationUser_Left = 1516, Organization_Updated = 1600, Organization_PurgedVault = 1601, diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index ea016e34350..d36aea241d5 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -27,6 +27,8 @@ export enum FeatureFlag { EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill", DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2", AccountDeprovisioning = "pm-10308-account-deprovisioning", + SSHKeyVaultItem = "ssh-key-vault-item", + SSHAgent = "ssh-agent", NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements", AC2476_DeprecateStripeSourcesAPI = "AC-2476-deprecate-stripe-sources-api", CipherKeyEncryption = "cipher-key-encryption", @@ -35,6 +37,9 @@ export enum FeatureFlag { AccessIntelligence = "pm-13227-access-intelligence", Pm13322AddPolicyDefinitions = "pm-13322-add-policy-definitions", LimitCollectionCreationDeletionSplit = "pm-10863-limit-collection-creation-deletion-split", + CriticalApps = "pm-14466-risk-insights-critical-application", + TrialPaymentOptional = "PM-8163-trial-payment", + SecurityTasks = "security-tasks", } export type AllowedFeatureFlagTypes = boolean | number | string; @@ -72,6 +77,8 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE, [FeatureFlag.DelayFido2PageScriptInitWithinMv2]: FALSE, [FeatureFlag.AccountDeprovisioning]: FALSE, + [FeatureFlag.SSHKeyVaultItem]: FALSE, + [FeatureFlag.SSHAgent]: FALSE, [FeatureFlag.NotificationBarAddLoginImprovements]: FALSE, [FeatureFlag.AC2476_DeprecateStripeSourcesAPI]: FALSE, [FeatureFlag.CipherKeyEncryption]: FALSE, @@ -80,6 +87,9 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.AccessIntelligence]: FALSE, [FeatureFlag.Pm13322AddPolicyDefinitions]: FALSE, [FeatureFlag.LimitCollectionCreationDeletionSplit]: FALSE, + [FeatureFlag.CriticalApps]: FALSE, + [FeatureFlag.TrialPaymentOptional]: FALSE, + [FeatureFlag.SecurityTasks]: FALSE, } satisfies Record; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue; diff --git a/libs/common/src/models/export/cipher.export.ts b/libs/common/src/models/export/cipher.export.ts index 64583f7fcef..432a2d4e250 100644 --- a/libs/common/src/models/export/cipher.export.ts +++ b/libs/common/src/models/export/cipher.export.ts @@ -10,6 +10,7 @@ import { IdentityExport } from "./identity.export"; import { LoginExport } from "./login.export"; import { PasswordHistoryExport } from "./password-history.export"; import { SecureNoteExport } from "./secure-note.export"; +import { SshKeyExport } from "./ssh-key.export"; import { safeGetString } from "./utils"; export class CipherExport { @@ -27,6 +28,7 @@ export class CipherExport { req.secureNote = null; req.card = null; req.identity = null; + req.sshKey = null; req.reprompt = CipherRepromptType.None; req.passwordHistory = []; req.creationDate = null; @@ -67,6 +69,8 @@ export class CipherExport { case CipherType.Identity: view.identity = IdentityExport.toView(req.identity); break; + case CipherType.SshKey: + view.sshKey = SshKeyExport.toView(req.sshKey); } if (req.passwordHistory != null) { @@ -108,6 +112,9 @@ export class CipherExport { case CipherType.Identity: domain.identity = IdentityExport.toDomain(req.identity); break; + case CipherType.SshKey: + domain.sshKey = SshKeyExport.toDomain(req.sshKey); + break; } if (req.passwordHistory != null) { @@ -132,6 +139,7 @@ export class CipherExport { secureNote: SecureNoteExport; card: CardExport; identity: IdentityExport; + sshKey: SshKeyExport; reprompt: CipherRepromptType; passwordHistory: PasswordHistoryExport[] = null; revisionDate: Date = null; @@ -171,6 +179,9 @@ export class CipherExport { case CipherType.Identity: this.identity = new IdentityExport(o.identity); break; + case CipherType.SshKey: + this.sshKey = new SshKeyExport(o.sshKey); + break; } if (o.passwordHistory != null) { diff --git a/libs/common/src/models/export/ssh-key.export.ts b/libs/common/src/models/export/ssh-key.export.ts new file mode 100644 index 00000000000..86683e97e20 --- /dev/null +++ b/libs/common/src/models/export/ssh-key.export.ts @@ -0,0 +1,44 @@ +import { SshKeyView as SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view"; + +import { EncString } from "../../platform/models/domain/enc-string"; +import { SshKey as SshKeyDomain } from "../../vault/models/domain/ssh-key"; + +import { safeGetString } from "./utils"; + +export class SshKeyExport { + static template(): SshKeyExport { + const req = new SshKeyExport(); + req.privateKey = ""; + req.publicKey = ""; + req.keyFingerprint = ""; + return req; + } + + static toView(req: SshKeyExport, view = new SshKeyView()) { + view.privateKey = req.privateKey; + view.publicKey = req.publicKey; + view.keyFingerprint = req.keyFingerprint; + return view; + } + + static toDomain(req: SshKeyExport, domain = new SshKeyDomain()) { + domain.privateKey = req.privateKey != null ? new EncString(req.privateKey) : null; + domain.publicKey = req.publicKey != null ? new EncString(req.publicKey) : null; + domain.keyFingerprint = req.keyFingerprint != null ? new EncString(req.keyFingerprint) : null; + return domain; + } + + privateKey: string; + publicKey: string; + keyFingerprint: string; + + constructor(o?: SshKeyView | SshKeyDomain) { + if (o == null) { + return; + } + + this.privateKey = safeGetString(o.privateKey); + this.publicKey = safeGetString(o.publicKey); + this.keyFingerprint = safeGetString(o.keyFingerprint); + } +} diff --git a/libs/common/src/platform/services/fido2/fido2-utils.spec.ts b/libs/common/src/platform/services/fido2/fido2-utils.spec.ts index a05eab52305..9bb4ed0a4c5 100644 --- a/libs/common/src/platform/services/fido2/fido2-utils.spec.ts +++ b/libs/common/src/platform/services/fido2/fido2-utils.spec.ts @@ -4,6 +4,36 @@ describe("Fido2 Utils", () => { const asciiHelloWorldArray = [104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100]; const b64HelloWorldString = "aGVsbG8gd29ybGQ="; + describe("bufferSourceToUint8Array(..)", () => { + it("should convert an ArrayBuffer", () => { + const buffer = new Uint8Array(asciiHelloWorldArray).buffer; + const out = Fido2Utils.bufferSourceToUint8Array(buffer); + expect(out).toEqual(new Uint8Array(asciiHelloWorldArray)); + }); + it("should convert an ArrayBuffer slice", () => { + const buffer = new Uint8Array(asciiHelloWorldArray).buffer.slice(8); + const out = Fido2Utils.bufferSourceToUint8Array(buffer); + expect(out).toEqual(new Uint8Array([114, 108, 100])); // 8th byte onwards + }); + it("should pass through an Uint8Array", () => { + const typedArray = new Uint8Array(asciiHelloWorldArray); + const out = Fido2Utils.bufferSourceToUint8Array(typedArray); + expect(out).toEqual(new Uint8Array(asciiHelloWorldArray)); + }); + it("should preserve the view of TypedArray", () => { + const buffer = new Uint8Array(asciiHelloWorldArray).buffer; + const input = new Uint8Array(buffer, 8, 1); + const out = Fido2Utils.bufferSourceToUint8Array(input); + expect(out).toEqual(new Uint8Array([114])); + }); + it("should convert different TypedArrays", () => { + const buffer = new Uint8Array(asciiHelloWorldArray).buffer; + const input = new Uint16Array(buffer, 8, 1); + const out = Fido2Utils.bufferSourceToUint8Array(input); + expect(out).toEqual(new Uint8Array([114, 108])); + }); + }); + describe("fromBufferToB64(...)", () => { it("should convert an ArrayBuffer to a b64 string", () => { const buffer = new Uint8Array(asciiHelloWorldArray).buffer; diff --git a/libs/common/src/platform/services/fido2/fido2-utils.ts b/libs/common/src/platform/services/fido2/fido2-utils.ts index c3c3eba246b..58034912978 100644 --- a/libs/common/src/platform/services/fido2/fido2-utils.ts +++ b/libs/common/src/platform/services/fido2/fido2-utils.ts @@ -1,13 +1,6 @@ export class Fido2Utils { static bufferToString(bufferSource: BufferSource): string { - let buffer: Uint8Array; - if (bufferSource instanceof ArrayBuffer || bufferSource.buffer === undefined) { - buffer = new Uint8Array(bufferSource as ArrayBuffer); - } else { - buffer = new Uint8Array(bufferSource.buffer); - } - - return Fido2Utils.fromBufferToB64(buffer) + return Fido2Utils.fromBufferToB64(Fido2Utils.bufferSourceToUint8Array(bufferSource)) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=/g, ""); @@ -18,12 +11,10 @@ export class Fido2Utils { } static bufferSourceToUint8Array(bufferSource: BufferSource): Uint8Array { - if (bufferSource instanceof Uint8Array) { - return bufferSource; - } else if (Fido2Utils.isArrayBuffer(bufferSource)) { + if (Fido2Utils.isArrayBuffer(bufferSource)) { return new Uint8Array(bufferSource); } else { - return new Uint8Array(bufferSource.buffer); + return new Uint8Array(bufferSource.buffer, bufferSource.byteOffset, bufferSource.byteLength); } } diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 2d4a0522636..0c508bfeb88 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -584,7 +584,7 @@ export class ApiService implements ApiServiceAbstraction { } putCipherCollectionsAdmin(id: string, request: CipherCollectionsRequest): Promise { - return this.send("PUT", "/ciphers/" + id + "/collections-admin", request, true, false); + return this.send("PUT", "/ciphers/" + id + "/collections-admin", request, true, true); } postPurgeCiphers( @@ -1886,7 +1886,7 @@ export class ApiService implements ApiServiceAbstraction { }); if (flagEnabled("prereleaseBuild")) { - headers.set("Is-Prerelease", "true"); + headers.set("Is-Prerelease", "1"); } if (this.customUserAgent != null) { headers.set("User-Agent", this.customUserAgent); diff --git a/libs/common/src/tools/state/user-state-subject.spec.ts b/libs/common/src/tools/state/user-state-subject.spec.ts index 9f5475df9de..ee78a5c048b 100644 --- a/libs/common/src/tools/state/user-state-subject.spec.ts +++ b/libs/common/src/tools/state/user-state-subject.spec.ts @@ -373,7 +373,11 @@ describe("UserStateSubject", () => { singleUserId$.next(SomeUser); await awaitAsync(); - expect(state.nextMock).toHaveBeenCalledWith({ foo: "next" }); + expect(state.nextMock).toHaveBeenCalledWith({ + foo: "next", + // FIXME: don't leak this detail into the test + "$^$ALWAYS_UPDATE_KLUDGE_PROPERTY$^$": 0, + }); }); it("waits to evaluate `UserState.update` until singleUserEncryptor$ emits", async () => { @@ -394,7 +398,13 @@ describe("UserStateSubject", () => { await awaitAsync(); const encrypted = { foo: "encrypt(next)" }; - expect(state.nextMock).toHaveBeenCalledWith({ id: null, secret: encrypted, disclosed: null }); + expect(state.nextMock).toHaveBeenCalledWith({ + id: null, + secret: encrypted, + disclosed: null, + // FIXME: don't leak this detail into the test + "$^$ALWAYS_UPDATE_KLUDGE_PROPERTY$^$": 0, + }); }); it("applies dynamic constraints", async () => { diff --git a/libs/common/src/tools/state/user-state-subject.ts b/libs/common/src/tools/state/user-state-subject.ts index 845ab25c808..0b562cc7a1f 100644 --- a/libs/common/src/tools/state/user-state-subject.ts +++ b/libs/common/src/tools/state/user-state-subject.ts @@ -43,6 +43,23 @@ import { UserStateSubjectDependencies } from "./user-state-subject-dependencies" type Constrained = { constraints: Readonly>; state: State }; +// FIXME: The subject should always repeat the value when it's own `next` method is called. +// +// Chrome StateService only calls `next` when the underlying values changes. When enforcing, +// say, a minimum constraint, any value beneath the minimum becomes the minimum. This prevents +// invalid data received in sequence from calling `next` because the state provider doesn't +// emit. +// +// The hack is pretty simple. Insert arbitrary data into the saved data to ensure +// that it *always* changes. +// +// Any real fix will be fairly complex because it needs to recognize *fast* when it +// is waiting. Alternatively, the kludge could become a format properly fed by random noise. +// +// NOTE: this only matters for plaintext objects; encrypted fields change with every +// update b/c their IVs change. +const ALWAYS_UPDATE_KLUDGE = "$^$ALWAYS_UPDATE_KLUDGE_PROPERTY$^$"; + /** * Adapt a state provider to an rxjs subject. * @@ -420,8 +437,25 @@ export class UserStateSubject< private inputSubscription: Unsubscribable; private outputSubscription: Unsubscribable; + private counter = 0; + private onNext(value: unknown) { - this.state.update(() => value).catch((e: any) => this.onError(e)); + this.state + .update(() => { + if (typeof value === "object") { + // related: ALWAYS_UPDATE_KLUDGE FIXME + const counter = this.counter++; + if (counter > Number.MAX_SAFE_INTEGER) { + this.counter = 0; + } + + const kludge = value as any; + kludge[ALWAYS_UPDATE_KLUDGE] = counter; + } + + return value; + }) + .catch((e: any) => this.onError(e)); } private onError(value: any) { diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 444c922fe31..5221f4cf0a6 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -119,7 +119,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider Promise; + saveCollectionsWithServerAdmin: (cipher: Cipher) => Promise; /** * Bulk update collections for many ciphers with the server * @param orgId diff --git a/libs/common/src/vault/enums/cipher-type.ts b/libs/common/src/vault/enums/cipher-type.ts index cce7874d667..0b7bbf1ee17 100644 --- a/libs/common/src/vault/enums/cipher-type.ts +++ b/libs/common/src/vault/enums/cipher-type.ts @@ -3,4 +3,5 @@ export enum CipherType { SecureNote = 2, Card = 3, Identity = 4, + SshKey = 5, } diff --git a/libs/common/src/vault/icon/build-cipher-icon.ts b/libs/common/src/vault/icon/build-cipher-icon.ts index 9e6e671f44d..78e6ecd7b4f 100644 --- a/libs/common/src/vault/icon/build-cipher-icon.ts +++ b/libs/common/src/vault/icon/build-cipher-icon.ts @@ -67,6 +67,9 @@ export function buildCipherIcon(iconsServerUrl: string, cipher: CipherView, show case CipherType.Identity: icon = "bwi-id-card"; break; + case CipherType.SshKey: + icon = "bwi-key"; + break; default: break; } diff --git a/libs/common/src/vault/models/api/ssh-key.api.ts b/libs/common/src/vault/models/api/ssh-key.api.ts new file mode 100644 index 00000000000..e14f72bbc6a --- /dev/null +++ b/libs/common/src/vault/models/api/ssh-key.api.ts @@ -0,0 +1,17 @@ +import { BaseResponse } from "../../../models/response/base.response"; + +export class SshKeyApi extends BaseResponse { + privateKey: string; + publicKey: string; + keyFingerprint: string; + + constructor(data: any = null) { + super(data); + if (data == null) { + return; + } + this.privateKey = this.getResponseProperty("PrivateKey"); + this.publicKey = this.getResponseProperty("PublicKey"); + this.keyFingerprint = this.getResponseProperty("KeyFingerprint"); + } +} diff --git a/libs/common/src/vault/models/data/cipher.data.ts b/libs/common/src/vault/models/data/cipher.data.ts index f8db7186d61..476c651f3ae 100644 --- a/libs/common/src/vault/models/data/cipher.data.ts +++ b/libs/common/src/vault/models/data/cipher.data.ts @@ -11,6 +11,7 @@ import { IdentityData } from "./identity.data"; import { LoginData } from "./login.data"; import { PasswordHistoryData } from "./password-history.data"; import { SecureNoteData } from "./secure-note.data"; +import { SshKeyData } from "./ssh-key.data"; export class CipherData { id: string; @@ -28,6 +29,7 @@ export class CipherData { secureNote?: SecureNoteData; card?: CardData; identity?: IdentityData; + sshKey?: SshKeyData; fields?: FieldData[]; attachments?: AttachmentData[]; passwordHistory?: PasswordHistoryData[]; @@ -72,6 +74,9 @@ export class CipherData { case CipherType.Identity: this.identity = new IdentityData(response.identity); break; + case CipherType.SshKey: + this.sshKey = new SshKeyData(response.sshKey); + break; default: break; } diff --git a/libs/common/src/vault/models/data/ssh-key.data.ts b/libs/common/src/vault/models/data/ssh-key.data.ts new file mode 100644 index 00000000000..32b6ec994f3 --- /dev/null +++ b/libs/common/src/vault/models/data/ssh-key.data.ts @@ -0,0 +1,17 @@ +import { SshKeyApi } from "../api/ssh-key.api"; + +export class SshKeyData { + privateKey: string; + publicKey: string; + keyFingerprint: string; + + constructor(data?: SshKeyApi) { + if (data == null) { + return; + } + + this.privateKey = data.privateKey; + this.publicKey = data.publicKey; + this.keyFingerprint = data.keyFingerprint; + } +} diff --git a/libs/common/src/vault/models/domain/cipher.ts b/libs/common/src/vault/models/domain/cipher.ts index 475c9337525..79536f5379a 100644 --- a/libs/common/src/vault/models/domain/cipher.ts +++ b/libs/common/src/vault/models/domain/cipher.ts @@ -19,6 +19,7 @@ import { Identity } from "./identity"; import { Login } from "./login"; import { Password } from "./password"; import { SecureNote } from "./secure-note"; +import { SshKey } from "./ssh-key"; export class Cipher extends Domain implements Decryptable { readonly initializerKey = InitializerKey.Cipher; @@ -39,6 +40,7 @@ export class Cipher extends Domain implements Decryptable { identity: Identity; card: Card; secureNote: SecureNote; + sshKey: SshKey; attachments: Attachment[]; fields: Field[]; passwordHistory: Password[]; @@ -97,6 +99,9 @@ export class Cipher extends Domain implements Decryptable { case CipherType.Identity: this.identity = new Identity(obj.identity); break; + case CipherType.SshKey: + this.sshKey = new SshKey(obj.sshKey); + break; default: break; } @@ -156,6 +161,9 @@ export class Cipher extends Domain implements Decryptable { case CipherType.Identity: model.identity = await this.identity.decrypt(this.organizationId, encKey); break; + case CipherType.SshKey: + model.sshKey = await this.sshKey.decrypt(this.organizationId, encKey); + break; default: break; } @@ -240,6 +248,9 @@ export class Cipher extends Domain implements Decryptable { case CipherType.Identity: c.identity = this.identity.toIdentityData(); break; + case CipherType.SshKey: + c.sshKey = this.sshKey.toSshKeyData(); + break; default: break; } @@ -295,6 +306,9 @@ export class Cipher extends Domain implements Decryptable { case CipherType.SecureNote: domain.secureNote = SecureNote.fromJSON(obj.secureNote); break; + case CipherType.SshKey: + domain.sshKey = SshKey.fromJSON(obj.sshKey); + break; default: break; } diff --git a/libs/common/src/vault/models/domain/ssh-key.spec.ts b/libs/common/src/vault/models/domain/ssh-key.spec.ts new file mode 100644 index 00000000000..f56d738fde8 --- /dev/null +++ b/libs/common/src/vault/models/domain/ssh-key.spec.ts @@ -0,0 +1,67 @@ +import { mockEnc } from "../../../../spec"; +import { SshKeyApi } from "../api/ssh-key.api"; +import { SshKeyData } from "../data/ssh-key.data"; + +import { SshKey } from "./ssh-key"; + +describe("Sshkey", () => { + let data: SshKeyData; + + beforeEach(() => { + data = new SshKeyData( + new SshKeyApi({ + PrivateKey: "privateKey", + PublicKey: "publicKey", + KeyFingerprint: "keyFingerprint", + }), + ); + }); + + it("Convert", () => { + const sshKey = new SshKey(data); + + expect(sshKey).toEqual({ + privateKey: { encryptedString: "privateKey", encryptionType: 0 }, + publicKey: { encryptedString: "publicKey", encryptionType: 0 }, + keyFingerprint: { encryptedString: "keyFingerprint", encryptionType: 0 }, + }); + }); + + it("Convert from empty", () => { + const data = new SshKeyData(); + const sshKey = new SshKey(data); + + expect(sshKey).toEqual({ + privateKey: null, + publicKey: null, + keyFingerprint: null, + }); + }); + + it("toSshKeyData", () => { + const sshKey = new SshKey(data); + expect(sshKey.toSshKeyData()).toEqual(data); + }); + + it("Decrypt", async () => { + const sshKey = Object.assign(new SshKey(), { + privateKey: mockEnc("privateKey"), + publicKey: mockEnc("publicKey"), + keyFingerprint: mockEnc("keyFingerprint"), + }); + const expectedView = { + privateKey: "privateKey", + publicKey: "publicKey", + keyFingerprint: "keyFingerprint", + }; + + const loginView = await sshKey.decrypt(null); + expect(loginView).toEqual(expectedView); + }); + + describe("fromJSON", () => { + it("returns null if object is null", () => { + expect(SshKey.fromJSON(null)).toBeNull(); + }); + }); +}); diff --git a/libs/common/src/vault/models/domain/ssh-key.ts b/libs/common/src/vault/models/domain/ssh-key.ts new file mode 100644 index 00000000000..e7c24b45ba8 --- /dev/null +++ b/libs/common/src/vault/models/domain/ssh-key.ts @@ -0,0 +1,70 @@ +import { Jsonify } from "type-fest"; + +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; + +import Domain from "../../../platform/models/domain/domain-base"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +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; + + constructor(obj?: SshKeyData) { + super(); + if (obj == null) { + return; + } + + this.buildDomainModel( + this, + obj, + { + privateKey: null, + publicKey: null, + keyFingerprint: null, + }, + [], + ); + } + + decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise { + return this.decryptObj( + new SshKeyView(), + { + privateKey: null, + publicKey: null, + keyFingerprint: null, + }, + orgId, + encKey, + ); + } + + toSshKeyData(): SshKeyData { + const c = new SshKeyData(); + this.buildDataModel(this, c, { + privateKey: null, + publicKey: null, + keyFingerprint: null, + }); + return c; + } + + static fromJSON(obj: Partial>): SshKey { + if (obj == null) { + return null; + } + + 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, + }); + } +} diff --git a/libs/common/src/vault/models/request/cipher.request.ts b/libs/common/src/vault/models/request/cipher.request.ts index 52a55b6c3e4..f24254f7432 100644 --- a/libs/common/src/vault/models/request/cipher.request.ts +++ b/libs/common/src/vault/models/request/cipher.request.ts @@ -7,6 +7,7 @@ import { IdentityApi } from "../api/identity.api"; import { LoginUriApi } from "../api/login-uri.api"; import { LoginApi } from "../api/login.api"; import { SecureNoteApi } from "../api/secure-note.api"; +import { SshKeyApi } from "../api/ssh-key.api"; import { Cipher } from "../domain/cipher"; import { AttachmentRequest } from "./attachment.request"; @@ -23,6 +24,7 @@ export class CipherRequest { secureNote: SecureNoteApi; card: CardApi; identity: IdentityApi; + sshKey: SshKeyApi; fields: FieldApi[]; passwordHistory: PasswordHistoryRequest[]; // Deprecated, remove at some point and rename attachments2 to attachments @@ -93,6 +95,17 @@ export class CipherRequest { this.secureNote = new SecureNoteApi(); this.secureNote.type = cipher.secureNote.type; break; + case CipherType.SshKey: + this.sshKey = new SshKeyApi(); + this.sshKey.privateKey = + cipher.sshKey.privateKey != null ? cipher.sshKey.privateKey.encryptedString : null; + this.sshKey.publicKey = + cipher.sshKey.publicKey != null ? cipher.sshKey.publicKey.encryptedString : null; + this.sshKey.keyFingerprint = + cipher.sshKey.keyFingerprint != null + ? cipher.sshKey.keyFingerprint.encryptedString + : null; + break; case CipherType.Card: this.card = new CardApi(); this.card.cardholderName = diff --git a/libs/common/src/vault/models/response/cipher.response.ts b/libs/common/src/vault/models/response/cipher.response.ts index 67709b602e3..7e2805b7510 100644 --- a/libs/common/src/vault/models/response/cipher.response.ts +++ b/libs/common/src/vault/models/response/cipher.response.ts @@ -5,6 +5,7 @@ import { FieldApi } from "../api/field.api"; import { IdentityApi } from "../api/identity.api"; import { LoginApi } from "../api/login.api"; import { SecureNoteApi } from "../api/secure-note.api"; +import { SshKeyApi } from "../api/ssh-key.api"; import { AttachmentResponse } from "./attachment.response"; import { PasswordHistoryResponse } from "./password-history.response"; @@ -21,6 +22,7 @@ export class CipherResponse extends BaseResponse { card: CardApi; identity: IdentityApi; secureNote: SecureNoteApi; + sshKey: SshKeyApi; favorite: boolean; edit: boolean; viewPassword: boolean; @@ -75,6 +77,11 @@ export class CipherResponse extends BaseResponse { this.secureNote = new SecureNoteApi(secureNote); } + const sshKey = this.getResponseProperty("sshKey"); + if (sshKey != null) { + this.sshKey = new SshKeyApi(sshKey); + } + const fields = this.getResponseProperty("Fields"); if (fields != null) { this.fields = fields.map((f: any) => new FieldApi(f)); diff --git a/libs/common/src/vault/models/view/cipher.view.ts b/libs/common/src/vault/models/view/cipher.view.ts index 3ea3f109be1..4d429bb390f 100644 --- a/libs/common/src/vault/models/view/cipher.view.ts +++ b/libs/common/src/vault/models/view/cipher.view.ts @@ -14,6 +14,7 @@ import { IdentityView } from "./identity.view"; import { LoginView } from "./login.view"; import { PasswordHistoryView } from "./password-history.view"; import { SecureNoteView } from "./secure-note.view"; +import { SshKeyView } from "./ssh-key.view"; export class CipherView implements View, InitializerMetadata { readonly initializerKey = InitializerKey.CipherView; @@ -33,6 +34,7 @@ export class CipherView implements View, InitializerMetadata { identity = new IdentityView(); card = new CardView(); secureNote = new SecureNoteView(); + sshKey = new SshKeyView(); attachments: AttachmentView[] = null; fields: FieldView[] = null; passwordHistory: PasswordHistoryView[] = null; @@ -74,6 +76,8 @@ export class CipherView implements View, InitializerMetadata { return this.card; case CipherType.Identity: return this.identity; + case CipherType.SshKey: + return this.sshKey; default: break; } @@ -190,6 +194,9 @@ export class CipherView implements View, InitializerMetadata { case CipherType.SecureNote: view.secureNote = SecureNoteView.fromJSON(obj.secureNote); break; + case CipherType.SshKey: + view.sshKey = SshKeyView.fromJSON(obj.sshKey); + break; default: break; } diff --git a/libs/common/src/vault/models/view/ssh-key.view.ts b/libs/common/src/vault/models/view/ssh-key.view.ts new file mode 100644 index 00000000000..4fedb1f8a36 --- /dev/null +++ b/libs/common/src/vault/models/view/ssh-key.view.ts @@ -0,0 +1,41 @@ +import { Jsonify } from "type-fest"; + +import { SshKey } from "../domain/ssh-key"; + +import { ItemView } from "./item.view"; + +export class SshKeyView extends ItemView { + privateKey: string = null; + publicKey: string = null; + keyFingerprint: string = null; + + constructor(n?: SshKey) { + super(); + if (!n) { + return; + } + } + + get maskedPrivateKey(): string { + let lines = this.privateKey.split("\n").filter((l) => l.trim() !== ""); + lines = lines.map((l, i) => { + if (i === 0 || i === lines.length - 1) { + return l; + } + return this.maskLine(l); + }); + return lines.join("\n"); + } + + private maskLine(line: string): string { + return "•".repeat(32); + } + + get subTitle(): string { + return this.keyFingerprint; + } + + static fromJSON(obj: Partial>): SshKeyView { + return Object.assign(new SshKeyView(), obj); + } +} diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 154042601e9..474976932e8 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -54,6 +54,7 @@ import { LoginUri } from "../models/domain/login-uri"; import { Password } from "../models/domain/password"; import { SecureNote } from "../models/domain/secure-note"; import { SortedCiphersCache } from "../models/domain/sorted-ciphers-cache"; +import { SshKey } from "../models/domain/ssh-key"; import { CipherBulkDeleteRequest } from "../models/request/cipher-bulk-delete.request"; import { CipherBulkMoveRequest } from "../models/request/cipher-bulk-move.request"; import { CipherBulkRestoreRequest } from "../models/request/cipher-bulk-restore.request"; @@ -880,9 +881,11 @@ export class CipherService implements CipherServiceAbstraction { return new Cipher(updated[cipher.id as CipherId], cipher.localData); } - async saveCollectionsWithServerAdmin(cipher: Cipher): Promise { + async saveCollectionsWithServerAdmin(cipher: Cipher): Promise { const request = new CipherCollectionsRequest(cipher.collectionIds); - await this.apiService.putCipherCollectionsAdmin(cipher.id, request); + const response = await this.apiService.putCipherCollectionsAdmin(cipher.id, request); + const data = new CipherData(response); + return new Cipher(data); } /** @@ -1568,6 +1571,19 @@ export class CipherService implements CipherServiceAbstraction { key, ); return; + case CipherType.SshKey: + cipher.sshKey = new SshKey(); + await this.encryptObjProperty( + model.sshKey, + cipher.sshKey, + { + privateKey: null, + publicKey: null, + keyFingerprint: null, + }, + key, + ); + return; default: throw new Error("Unknown cipher type."); } diff --git a/libs/components/src/disclosure/disclosure-trigger-for.directive.ts b/libs/components/src/disclosure/disclosure-trigger-for.directive.ts new file mode 100644 index 00000000000..05470281729 --- /dev/null +++ b/libs/components/src/disclosure/disclosure-trigger-for.directive.ts @@ -0,0 +1,27 @@ +import { Directive, HostBinding, HostListener, Input } from "@angular/core"; + +import { DisclosureComponent } from "./disclosure.component"; + +@Directive({ + selector: "[bitDisclosureTriggerFor]", + exportAs: "disclosureTriggerFor", + standalone: true, +}) +export class DisclosureTriggerForDirective { + /** + * Accepts template reference for a bit-disclosure component instance + */ + @Input("bitDisclosureTriggerFor") disclosure: DisclosureComponent; + + @HostBinding("attr.aria-expanded") get ariaExpanded() { + return this.disclosure.open; + } + + @HostBinding("attr.aria-controls") get ariaControls() { + return this.disclosure.id; + } + + @HostListener("click") click() { + this.disclosure.open = !this.disclosure.open; + } +} diff --git a/libs/components/src/disclosure/disclosure.component.ts b/libs/components/src/disclosure/disclosure.component.ts new file mode 100644 index 00000000000..58c67ad0f0e --- /dev/null +++ b/libs/components/src/disclosure/disclosure.component.ts @@ -0,0 +1,21 @@ +import { Component, HostBinding, Input, booleanAttribute } from "@angular/core"; + +let nextId = 0; + +@Component({ + selector: "bit-disclosure", + standalone: true, + template: ``, +}) +export class DisclosureComponent { + /** + * Optionally init the disclosure in its opened state + */ + @Input({ transform: booleanAttribute }) open?: boolean = false; + + @HostBinding("class") get classList() { + return this.open ? "" : "tw-hidden"; + } + + @HostBinding("id") id = `bit-disclosure-${nextId++}`; +} diff --git a/libs/components/src/disclosure/disclosure.mdx b/libs/components/src/disclosure/disclosure.mdx new file mode 100644 index 00000000000..8df8e7025b8 --- /dev/null +++ b/libs/components/src/disclosure/disclosure.mdx @@ -0,0 +1,55 @@ +import { Meta, Story, Primary, Controls } from "@storybook/addon-docs"; + +import * as stories from "./disclosure.stories"; + + + +```ts +import { DisclosureComponent, DisclosureTriggerForDirective } from "@bitwarden/components"; +``` + +# Disclosure + +The `bit-disclosure` component is used in tandem with the `bitDisclosureTriggerFor` directive to +create an accessible content area whose visibility is controlled by a trigger button. + +To compose a disclosure and trigger: + +1. Create a trigger component (see "Supported Trigger Components" section below) +2. Create a `bit-disclosure` +3. Set a template reference on the `bit-disclosure` +4. Use the `bitDisclosureTriggerFor` directive on the trigger component, and pass it the + `bit-disclosure` template reference +5. Set the `open` property on the `bit-disclosure` to init the disclosure as either currently + expanded or currently collapsed. The disclosure will default to `false`, meaning it defaults to + being hidden. + +``` + +click button to hide this content +``` + + + + + + +## Supported Trigger Components + +This is the list of currently supported trigger components: + +- Icon button `muted` variant + +## Accessibility + +The disclosure and trigger directive functionality follow the +[Disclosure (Show/Hide)](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/) pattern for +accessibility, automatically handling the `aria-controls` and `aria-expanded` properties. A `button` +element must be used as the trigger for the disclosure. The `button` element must also have an +accessible label/title -- please follow the accessibility guidelines for whatever trigger component +you choose. diff --git a/libs/components/src/disclosure/disclosure.stories.ts b/libs/components/src/disclosure/disclosure.stories.ts new file mode 100644 index 00000000000..974589a667c --- /dev/null +++ b/libs/components/src/disclosure/disclosure.stories.ts @@ -0,0 +1,29 @@ +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; + +import { IconButtonModule } from "../icon-button"; + +import { DisclosureTriggerForDirective } from "./disclosure-trigger-for.directive"; +import { DisclosureComponent } from "./disclosure.component"; + +export default { + title: "Component Library/Disclosure", + component: DisclosureComponent, + decorators: [ + moduleMetadata({ + imports: [DisclosureTriggerForDirective, DisclosureComponent, IconButtonModule], + }), + ], +} as Meta; + +type Story = StoryObj; + +export const DisclosureWithIconButton: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + + click button to hide this content + `, + }), +}; diff --git a/libs/components/src/disclosure/index.ts b/libs/components/src/disclosure/index.ts new file mode 100644 index 00000000000..b5bdf68725f --- /dev/null +++ b/libs/components/src/disclosure/index.ts @@ -0,0 +1,2 @@ +export * from "./disclosure-trigger-for.directive"; +export * from "./disclosure.component"; diff --git a/libs/components/src/icon-button/icon-button.component.ts b/libs/components/src/icon-button/icon-button.component.ts index 54f6dfda963..d036e1c77ca 100644 --- a/libs/components/src/icon-button/icon-button.component.ts +++ b/libs/components/src/icon-button/icon-button.component.ts @@ -52,10 +52,14 @@ const styles: Record = { "tw-bg-transparent", "!tw-text-muted", "tw-border-transparent", + "aria-expanded:tw-bg-text-muted", + "aria-expanded:!tw-text-contrast", "hover:tw-bg-transparent-hover", "hover:tw-border-primary-700", "focus-visible:before:tw-ring-primary-700", "disabled:tw-opacity-60", + "aria-expanded:hover:tw-bg-secondary-700", + "aria-expanded:hover:tw-border-secondary-700", "disabled:hover:tw-border-transparent", "disabled:hover:tw-bg-transparent", ...focusRing, diff --git a/libs/components/src/icon-button/icon-button.mdx b/libs/components/src/icon-button/icon-button.mdx index 8361d4c3997..a45160d7884 100644 --- a/libs/components/src/icon-button/icon-button.mdx +++ b/libs/components/src/icon-button/icon-button.mdx @@ -29,8 +29,6 @@ Icon buttons can be found in other components such as: the [dialog](?path=/docs/component-library-dialogs--docs), and [table](?path=/docs/component-library-table--docs). - - ## Styles There are 4 common styles for button main, muted, contrast, and danger. The other styles follow the @@ -40,48 +38,48 @@ button component styles. Used for general icon buttons appearing on the theme’s main `background` - + ### Muted Used for low emphasis icon buttons appearing on the theme’s main `background` - + ### Contrast Used on a theme’s colored or contrasting backgrounds such as in the navigation or on toasts and banners. - + ### Danger Danger is used for “trash” actions throughout the experience, most commonly in the bottom right of the dialog component. - + ### Primary Used in place of the main button component if no text is used. This allows the button to display square. - + ### Secondary Used in place of the main button component if no text is used. This allows the button to display square. - + ### Light Used on a background that is dark in both light theme and dark theme. Example: end user navigation styles. - + **Note:** Main and contrast styles appear on backgrounds where using `primary-700` as a focus indicator does not meet WCAG graphic contrast guidelines. @@ -95,11 +93,11 @@ with less padding around the icon, such as in the navigation component. ### Small - + ### Default - + ## Accessibility diff --git a/libs/components/src/icon-button/icon-button.stories.ts b/libs/components/src/icon-button/icon-button.stories.ts index 0f25d2de583..b5542f78600 100644 --- a/libs/components/src/icon-button/icon-button.stories.ts +++ b/libs/components/src/icon-button/icon-button.stories.ts @@ -23,7 +23,7 @@ type Story = StoryObj; export const Default: Story = { render: (args) => ({ props: args, - template: ` + template: /*html*/ ` Button Button @@ -56,7 +56,7 @@ export const Small: Story = { export const Primary: Story = { render: (args) => ({ props: args, - template: ` + template: /*html*/ ` Button `, }), @@ -96,7 +96,7 @@ export const Muted: Story = { export const Light: Story = { render: (args) => ({ props: args, - template: ` + template: /*html*/ ` Button @@ -110,7 +110,7 @@ export const Light: Story = { export const Contrast: Story = { render: (args) => ({ props: args, - template: ` + template: /*html*/ ` Button diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index 6881d801e0f..810f32bdd3c 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -13,6 +13,7 @@ export * from "./chip-select"; export * from "./color-password"; export * from "./container"; export * from "./dialog"; +export * from "./disclosure"; export * from "./form-field"; export * from "./icon-button"; export * from "./icon"; diff --git a/libs/components/src/table/sortable.component.ts b/libs/components/src/table/sortable.component.ts index b2e456953b1..c6d60f155b2 100644 --- a/libs/components/src/table/sortable.component.ts +++ b/libs/components/src/table/sortable.component.ts @@ -1,7 +1,7 @@ import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { Component, HostBinding, Input, OnInit } from "@angular/core"; -import type { SortFn } from "./table-data-source"; +import type { SortDirection, SortFn } from "./table-data-source"; import { TableComponent } from "./table.component"; @Component({ @@ -19,12 +19,16 @@ export class SortableComponent implements OnInit { */ @Input() bitSortable: string; - private _default: boolean; + private _default: SortDirection | boolean = false; /** * Mark the column as the default sort column */ - @Input() set default(value: boolean | "") { - this._default = coerceBooleanProperty(value); + @Input() set default(value: SortDirection | boolean | "") { + if (value === "desc" || value === "asc") { + this._default = value; + } else { + this._default = coerceBooleanProperty(value) ? "asc" : false; + } } /** @@ -32,6 +36,11 @@ export class SortableComponent implements OnInit { * * @example * fn = (a, b) => a.name.localeCompare(b.name) + * + * fn = (a, b, direction) => { + * const result = a.name.localeCompare(b.name) + * return direction === 'asc' ? result : -result; + * } */ @Input() fn: SortFn; @@ -52,8 +61,18 @@ export class SortableComponent implements OnInit { protected setActive() { if (this.table.dataSource) { - const direction = this.isActive && this.direction === "asc" ? "desc" : "asc"; - this.table.dataSource.sort = { column: this.bitSortable, direction: direction, fn: this.fn }; + const defaultDirection = this._default === "desc" ? "desc" : "asc"; + const direction = this.isActive + ? this.direction === "asc" + ? "desc" + : "asc" + : defaultDirection; + + this.table.dataSource.sort = { + column: this.bitSortable, + direction: direction, + fn: this.fn, + }; } } diff --git a/libs/components/src/table/table-data-source.ts b/libs/components/src/table/table-data-source.ts index 6501c9bffbd..8a5d994dc18 100644 --- a/libs/components/src/table/table-data-source.ts +++ b/libs/components/src/table/table-data-source.ts @@ -3,7 +3,7 @@ import { DataSource } from "@angular/cdk/collections"; import { BehaviorSubject, combineLatest, map, Observable, Subscription } from "rxjs"; export type SortDirection = "asc" | "desc"; -export type SortFn = (a: any, b: any) => number; +export type SortFn = (a: any, b: any, direction?: SortDirection) => number; export type Sort = { column?: string; direction: SortDirection; @@ -166,7 +166,7 @@ export class TableDataSource extends DataSource { return data.sort((a, b) => { // If a custom sort function is provided, use it instead of the default. if (sort.fn) { - return sort.fn(a, b) * directionModifier; + return sort.fn(a, b, sort.direction) * directionModifier; } let valueA = this.sortingDataAccessor(a, column); diff --git a/libs/components/src/table/table.mdx b/libs/components/src/table/table.mdx index 3f28dd93b68..8d784190ed9 100644 --- a/libs/components/src/table/table.mdx +++ b/libs/components/src/table/table.mdx @@ -105,7 +105,7 @@ within the `ng-template`which provides access to the rows using `let-rows$`. We provide a simple component for displaying sortable column headers. The `bitSortable` component wires up to the `TableDataSource` and will automatically sort the data when clicked and display an -indicator for which column is currently sorted. The dafault sorting can be specified by setting the +indicator for which column is currently sorted. The default sorting can be specified by setting the `default`. ```html @@ -113,10 +113,23 @@ indicator for which column is currently sorted. The dafault sorting can be speci Name ``` +For default sorting in descending order, set default="desc" + +```html +Name +``` + It's also possible to define a custom sorting function by setting the `fn` input. ```ts +// Basic sort function const sortFn = (a: T, b: T) => (a.id > b.id ? 1 : -1); + +// Direction aware sort function +const sortByName = (a: T, b: T, direction?: SortDirection) => { + const result = a.name.localeCompare(b.name); + return direction === "asc" ? result : -result; +}; ``` ### Filtering diff --git a/libs/importer/src/components/dialog/import-success-dialog.component.ts b/libs/importer/src/components/dialog/import-success-dialog.component.ts index 1e0d4f7fda9..75e0754423e 100644 --- a/libs/importer/src/components/dialog/import-success-dialog.component.ts +++ b/libs/importer/src/components/dialog/import-success-dialog.component.ts @@ -38,6 +38,7 @@ export class ImportSuccessDialogComponent implements OnInit { let cards = 0; let identities = 0; let secureNotes = 0; + let sshKeys = 0; this.data.ciphers.map((c) => { switch (c.type) { case CipherType.Login: @@ -52,6 +53,9 @@ export class ImportSuccessDialogComponent implements OnInit { case CipherType.Identity: identities++; break; + case CipherType.SshKey: + sshKeys++; + break; default: break; } @@ -70,6 +74,9 @@ export class ImportSuccessDialogComponent implements OnInit { if (secureNotes > 0) { list.push({ icon: "sticky-note", type: "typeSecureNote", count: secureNotes }); } + if (sshKeys > 0) { + list.push({ icon: "key", type: "typeSSHKey", count: sshKeys }); + } if (this.data.folders.length > 0) { list.push({ icon: "folder", type: "folders", count: this.data.folders.length }); } diff --git a/libs/tools/generator/components/src/catchall-settings.component.ts b/libs/tools/generator/components/src/catchall-settings.component.ts index 74fb37d2335..3a42d682971 100644 --- a/libs/tools/generator/components/src/catchall-settings.component.ts +++ b/libs/tools/generator/components/src/catchall-settings.component.ts @@ -12,11 +12,6 @@ import { import { completeOnAccountSwitch } from "./util"; -/** Splits an email into a username, subaddress, and domain named group. - * Subaddress is optional. - */ -export const DOMAIN_PARSER = new RegExp("[^@]+@(?.+)"); - /** Options group for catchall emails */ @Component({ selector: "tools-catchall-settings", diff --git a/libs/tools/generator/components/src/credential-generator.component.html b/libs/tools/generator/components/src/credential-generator.component.html index 0182bd1c204..ce86abe80ae 100644 --- a/libs/tools/generator/components/src/credential-generator.component.html +++ b/libs/tools/generator/components/src/credential-generator.component.html @@ -39,14 +39,12 @@ [value, value > 0] as const), - tap(([value]) => (lastMinNumber = this.numbers.value ? value : lastMinNumber)), + tap(([value, checkNumbers]) => (lastMinNumber = checkNumbers ? value : lastMinNumber)), takeUntil(this.destroyed$), ) - .subscribe(([, checked]) => this.numbers.setValue(checked, { emitEvent: false })); + .subscribe(([, checkNumbers]) => this.numbers.setValue(checkNumbers, { emitEvent: false })); let lastMinSpecial = 1; this.special.valueChanges @@ -188,10 +188,10 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy { this.minSpecial.valueChanges .pipe( map((value) => [value, value > 0] as const), - tap(([value]) => (lastMinSpecial = this.special.value ? value : lastMinSpecial)), + tap(([value, checkSpecial]) => (lastMinSpecial = checkSpecial ? value : lastMinSpecial)), takeUntil(this.destroyed$), ) - .subscribe(([, checked]) => this.special.setValue(checked, { emitEvent: false })); + .subscribe(([, checkSpecial]) => this.special.setValue(checkSpecial, { emitEvent: false })); // `onUpdated` depends on `settings` because the UserStateSubject is asynchronous; // subscribing directly to `this.settings.valueChanges` introduces a race condition. diff --git a/libs/tools/generator/components/src/username-generator.component.html b/libs/tools/generator/components/src/username-generator.component.html index f96374e063b..31e224713ed 100644 --- a/libs/tools/generator/components/src/username-generator.component.html +++ b/libs/tools/generator/components/src/username-generator.component.html @@ -59,25 +59,21 @@ = { min: 0, max: 0 }; const AtLeastOne: Constraint = { min: 1 }; const RequiresTrue: Constraint = { requiredValue: true }; @@ -159,6 +160,7 @@ export { enforceConstant, readonlyTrueWhen, fitLength, + Zero, AtLeastOne, RequiresTrue, }; diff --git a/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.spec.ts b/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.spec.ts index 96f590f8ed6..d05d75ffb76 100644 --- a/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.spec.ts +++ b/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.spec.ts @@ -1,6 +1,6 @@ import { DefaultPasswordBoundaries, DefaultPasswordGenerationOptions, Policies } from "../data"; -import { AtLeastOne } from "./constraints"; +import { AtLeastOne, Zero } from "./constraints"; import { DynamicPasswordPolicyConstraints } from "./dynamic-password-policy-constraints"; describe("DynamicPasswordPolicyConstraints", () => { @@ -207,7 +207,7 @@ describe("DynamicPasswordPolicyConstraints", () => { expect(calibrated.constraints.minNumber).toEqual(dynamic.constraints.minNumber); }); - it("disables the minNumber constraint when the state's number flag is false", () => { + it("outputs the zero constraint when the state's number flag is false", () => { const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue); const state = { ...DefaultPasswordGenerationOptions, @@ -216,7 +216,7 @@ describe("DynamicPasswordPolicyConstraints", () => { const calibrated = dynamic.calibrate(state); - expect(calibrated.constraints.minNumber).toBeUndefined(); + expect(calibrated.constraints.minNumber).toEqual(Zero); }); it("outputs the minSpecial constraint when the state's special flag is true", () => { @@ -231,7 +231,7 @@ describe("DynamicPasswordPolicyConstraints", () => { expect(calibrated.constraints.minSpecial).toEqual(dynamic.constraints.minSpecial); }); - it("disables the minSpecial constraint when the state's special flag is false", () => { + it("outputs the zero constraint when the state's special flag is false", () => { const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue); const state = { ...DefaultPasswordGenerationOptions, @@ -240,23 +240,7 @@ describe("DynamicPasswordPolicyConstraints", () => { const calibrated = dynamic.calibrate(state); - expect(calibrated.constraints.minSpecial).toBeUndefined(); - }); - - it("copies the minimum length constraint", () => { - const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue); - - const calibrated = dynamic.calibrate(DefaultPasswordGenerationOptions); - - expect(calibrated.constraints.minSpecial).toBeUndefined(); - }); - - it("overrides the minimum length constraint when it is less than the sum of the state's minimums", () => { - const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue); - - const calibrated = dynamic.calibrate(DefaultPasswordGenerationOptions); - - expect(calibrated.constraints.minSpecial).toBeUndefined(); + expect(calibrated.constraints.minSpecial).toEqual(Zero); }); }); }); diff --git a/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.ts b/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.ts index daff9882547..7fe76061885 100644 --- a/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.ts +++ b/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.ts @@ -7,7 +7,7 @@ import { import { DefaultPasswordBoundaries } from "../data"; import { PasswordGeneratorPolicy, PasswordGeneratorSettings } from "../types"; -import { atLeast, atLeastSum, maybe, readonlyTrueWhen, AtLeastOne } from "./constraints"; +import { atLeast, atLeastSum, maybe, readonlyTrueWhen, AtLeastOne, Zero } from "./constraints"; import { PasswordPolicyConstraints } from "./password-policy-constraints"; /** Creates state constraints by blending policy and password settings. */ @@ -68,8 +68,8 @@ export class DynamicPasswordPolicyConstraints ...this.constraints, minLowercase: maybe(lowercase, this.constraints.minLowercase ?? AtLeastOne), minUppercase: maybe(uppercase, this.constraints.minUppercase ?? AtLeastOne), - minNumber: maybe(number, this.constraints.minNumber), - minSpecial: maybe(special, this.constraints.minSpecial), + minNumber: maybe(number, this.constraints.minNumber) ?? Zero, + minSpecial: maybe(special, this.constraints.minSpecial) ?? Zero, }; // lower bound of length must always at least fit its sub-lengths diff --git a/libs/tools/generator/core/src/services/credential-generator.service.spec.ts b/libs/tools/generator/core/src/services/credential-generator.service.spec.ts index bd26642157e..b6b43073431 100644 --- a/libs/tools/generator/core/src/services/credential-generator.service.spec.ts +++ b/libs/tools/generator/core/src/services/credential-generator.service.spec.ts @@ -1163,7 +1163,11 @@ describe("CredentialGeneratorService", () => { await awaitAsync(); const result = await firstValueFrom(stateProvider.getUserState$(SettingsKey, SomeUser)); - expect(result).toEqual({ foo: "next value" }); + expect(result).toEqual({ + foo: "next value", + // FIXME: don't leak this detail into the test + "$^$ALWAYS_UPDATE_KLUDGE_PROPERTY$^$": 0, + }); }); it("waits for the user to become available", async () => { diff --git a/libs/vault/src/cipher-form/cipher-form-container.ts b/libs/vault/src/cipher-form/cipher-form-container.ts index 87495df643f..8010cf260df 100644 --- a/libs/vault/src/cipher-form/cipher-form-container.ts +++ b/libs/vault/src/cipher-form/cipher-form-container.ts @@ -8,6 +8,7 @@ import { CustomFieldsComponent } from "./components/custom-fields/custom-fields. import { IdentitySectionComponent } from "./components/identity/identity.component"; import { ItemDetailsSectionComponent } from "./components/item-details/item-details-section.component"; import { LoginDetailsSectionComponent } from "./components/login-details-section/login-details-section.component"; +import { SshKeySectionComponent } from "./components/sshkey-section/sshkey-section.component"; /** * The complete form for a cipher. Includes all the sub-forms from their respective section components. @@ -20,6 +21,7 @@ export type CipherForm = { autoFillOptions?: AutofillOptionsComponent["autofillOptionsForm"]; cardDetails?: CardDetailsSectionComponent["cardDetailsForm"]; identityDetails?: IdentitySectionComponent["identityForm"]; + sshKeyDetails?: SshKeySectionComponent["sshKeyForm"]; customFields?: CustomFieldsComponent["customFieldsForm"]; }; diff --git a/libs/vault/src/cipher-form/components/cipher-form.component.html b/libs/vault/src/cipher-form/components/cipher-form.component.html index 60dbd91fc36..2644741385b 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.html +++ b/libs/vault/src/cipher-form/components/cipher-form.component.html @@ -22,6 +22,12 @@ [disabled]="config.mode === 'partial-edit'" > + + 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 4df6aa67ea6..d1bbbef0910 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.ts +++ b/libs/vault/src/cipher-form/components/cipher-form.component.ts @@ -42,6 +42,7 @@ import { CardDetailsSectionComponent } from "./card-details-section/card-details import { IdentitySectionComponent } from "./identity/identity.component"; import { ItemDetailsSectionComponent } from "./item-details/item-details-section.component"; import { LoginDetailsSectionComponent } from "./login-details-section/login-details-section.component"; +import { SshKeySectionComponent } from "./sshkey-section/sshkey-section.component"; @Component({ selector: "vault-cipher-form", @@ -65,6 +66,7 @@ import { LoginDetailsSectionComponent } from "./login-details-section/login-deta ItemDetailsSectionComponent, CardDetailsSectionComponent, IdentitySectionComponent, + SshKeySectionComponent, NgIf, AdditionalOptionsSectionComponent, LoginDetailsSectionComponent, diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts index b62557a4329..93229bda6c3 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts @@ -87,7 +87,12 @@ describe("ItemDetailsSectionComponent", () => { component.config.allowPersonalOwnership = true; component.config.organizations = [{ id: "org1" } as Organization]; component.config.collections = [ - { id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView, + { + id: "col1", + name: "Collection 1", + organizationId: "org1", + canEditItems: (_org) => true, + } as CollectionView, ]; component.originalCipherView = { name: "cipher1", @@ -116,8 +121,18 @@ describe("ItemDetailsSectionComponent", () => { component.config.allowPersonalOwnership = true; component.config.organizations = [{ id: "org1" } as Organization]; component.config.collections = [ - { id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView, - { id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView, + { + id: "col1", + name: "Collection 1", + organizationId: "org1", + canEditItems: (_org) => false, + } as CollectionView, + { + id: "col2", + name: "Collection 2", + organizationId: "org1", + canEditItems: (_org) => true, + } as CollectionView, ]; component.originalCipherView = { name: "cipher1", @@ -367,9 +382,24 @@ describe("ItemDetailsSectionComponent", () => { } as CipherView; component.config.organizations = [{ id: "org1" } as Organization]; component.config.collections = [ - { id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView, - { id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView, - { id: "col3", name: "Collection 3", organizationId: "org1" } as CollectionView, + { + id: "col1", + name: "Collection 1", + organizationId: "org1", + canEditItems: (_org) => true, + } as CollectionView, + { + id: "col2", + name: "Collection 2", + organizationId: "org1", + canEditItems: (_org) => true, + } as CollectionView, + { + id: "col3", + name: "Collection 3", + organizationId: "org1", + canEditItems: (_org) => true, + } as CollectionView, ]; fixture.detectChanges(); @@ -387,7 +417,12 @@ describe("ItemDetailsSectionComponent", () => { component.config.allowPersonalOwnership = true; component.config.organizations = [{ id: "org1" } as Organization]; component.config.collections = [ - { id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView, + { + id: "col1", + name: "Collection 1", + organizationId: "org1", + canEditItems: (_org) => true, + } as CollectionView, ]; fixture.detectChanges(); @@ -414,13 +449,24 @@ describe("ItemDetailsSectionComponent", () => { } as CipherView; component.config.organizations = [{ id: "org1" } as Organization]; component.config.collections = [ - { id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView, - { id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView, + { + id: "col1", + name: "Collection 1", + organizationId: "org1", + canEditItems: (_org) => true, + } as CollectionView, + { + id: "col2", + name: "Collection 2", + organizationId: "org1", + canEditItems: (_org) => true, + } as CollectionView, { id: "col3", name: "Collection 3", organizationId: "org1", readOnly: true, + canEditItems: (_org) => true, } as CollectionView, ]; @@ -433,5 +479,94 @@ describe("ItemDetailsSectionComponent", () => { expect(collectionHint).not.toBeNull(); }); + + it("should allow all collections to be altered when `config.admin` is true", async () => { + component.config.admin = true; + component.config.allowPersonalOwnership = true; + component.config.organizations = [{ id: "org1" } as Organization]; + component.config.collections = [ + { + id: "col1", + name: "Collection 1", + organizationId: "org1", + readOnly: true, + canEditItems: (_org) => false, + } as CollectionView, + { + id: "col2", + name: "Collection 2", + organizationId: "org1", + readOnly: true, + canEditItems: (_org) => false, + } as CollectionView, + { + id: "col3", + name: "Collection 3", + organizationId: "org1", + readOnly: false, + canEditItems: (_org) => false, + } as CollectionView, + ]; + + fixture.detectChanges(); + await fixture.whenStable(); + + component.itemDetailsForm.controls.organizationId.setValue("org1"); + + expect(component["collectionOptions"].map((c) => c.id)).toEqual(["col1", "col2", "col3"]); + }); + }); + + describe("readonlyCollections", () => { + beforeEach(() => { + component.config.mode = "edit"; + component.config.admin = true; + component.config.collections = [ + { + id: "col1", + name: "Collection 1", + organizationId: "org1", + readOnly: true, + canEditItems: (_org) => false, + } as CollectionView, + { + id: "col2", + name: "Collection 2", + organizationId: "org1", + canEditItems: (_org) => false, + } as CollectionView, + { + id: "col3", + name: "Collection 3", + organizationId: "org1", + readOnly: true, + canEditItems: (_org) => false, + } as CollectionView, + ]; + component.originalCipherView = { + name: "cipher1", + organizationId: "org1", + folderId: "folder1", + collectionIds: ["col1", "col2", "col3"], + favorite: true, + } as CipherView; + component.config.organizations = [{ id: "org1" } as Organization]; + }); + + it("should not show collections as readonly when `config.admin` is true", async () => { + await component.ngOnInit(); + fixture.detectChanges(); + + // Filters out all collections + expect(component["readOnlyCollections"]).toEqual([]); + + // Non-admin, keep readonly collections + component.config.admin = false; + + await component.ngOnInit(); + fixture.detectChanges(); + + expect(component["readOnlyCollections"]).toEqual(["Collection 1", "Collection 3"]); + }); }); }); 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 86a8818bbe3..ea82aa0cae4 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 @@ -240,7 +240,11 @@ export class ItemDetailsSectionComponent implements OnInit { } else if (this.config.mode === "edit") { this.readOnlyCollections = this.collections .filter( - (c) => c.readOnly && this.originalCipherView.collectionIds.includes(c.id as CollectionId), + // When the configuration is set up for admins, they can alter read only collections + (c) => + c.readOnly && + !this.config.admin && + this.originalCipherView.collectionIds.includes(c.id as CollectionId), ) .map((c) => c.name); } @@ -262,12 +266,24 @@ export class ItemDetailsSectionComponent implements OnInit { collectionsControl.disable(); this.showCollectionsControl = false; return; + } else { + collectionsControl.enable(); + this.showCollectionsControl = true; } + const organization = this.organizations.find((o) => o.id === orgId); + this.collectionOptions = this.collections .filter((c) => { - // If partial edit mode, show all org collections because the control is disabled. - return c.organizationId === orgId && (this.partialEdit || !c.readOnly); + // Filter criteria: + // - The collection belongs to the organization + // - When in partial edit mode, show all org collections because the control is disabled. + // - The user can edit items within the collection + // - When viewing as an admin, all collections should be shown, even readonly. When non-admin, filter out readonly collections + return ( + c.organizationId === orgId && + (this.partialEdit || c.canEditItems(organization) || this.config.admin) + ); }) .map((c) => ({ id: c.id, diff --git a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.html b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.html new file mode 100644 index 00000000000..51b07a1cbf3 --- /dev/null +++ b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.html @@ -0,0 +1,30 @@ + + + + {{ "typeSshKey" | i18n }} + + + + + {{ "sshPrivateKey" | i18n }} + + + + + + {{ "sshPublicKey" | i18n }} + + + + + {{ "sshFingerprint" | i18n }} + + + + 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 new file mode 100644 index 00000000000..a15237421bd --- /dev/null +++ b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts @@ -0,0 +1,80 @@ +import { CommonModule } from "@angular/common"; +import { Component, Input, OnInit } from "@angular/core"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + CardComponent, + FormFieldModule, + IconButtonModule, + SectionComponent, + SectionHeaderComponent, + SelectModule, + TypographyModule, +} from "@bitwarden/components"; + +import { CipherFormContainer } from "../../cipher-form-container"; + +@Component({ + selector: "vault-sshkey-section", + templateUrl: "./sshkey-section.component.html", + standalone: true, + imports: [ + CardComponent, + SectionComponent, + TypographyModule, + FormFieldModule, + ReactiveFormsModule, + SelectModule, + SectionHeaderComponent, + IconButtonModule, + JslibModule, + CommonModule, + ], +}) +export class SshKeySectionComponent implements OnInit { + /** The original cipher */ + @Input() originalCipherView: CipherView; + + /** True when all fields should be disabled */ + @Input() disabled: boolean; + + /** + * All form fields associated with the ssh key + * + * Note: `as` is used to assert the type of the form control, + * leaving as just null gets inferred as `unknown` + */ + sshKeyForm = this.formBuilder.group({ + privateKey: null as string | null, + publicKey: null as string | null, + keyFingerprint: null as string | null, + }); + + constructor( + private cipherFormContainer: CipherFormContainer, + private formBuilder: FormBuilder, + private i18nService: I18nService, + ) {} + + ngOnInit() { + if (this.originalCipherView?.card) { + this.setInitialValues(); + } + + this.sshKeyForm.disable(); + } + + /** Set form initial form values from the current cipher */ + private setInitialValues() { + const { privateKey, publicKey, keyFingerprint } = this.originalCipherView.sshKey; + + this.sshKeyForm.setValue({ + privateKey, + publicKey, + keyFingerprint, + }); + } +} diff --git a/libs/vault/src/cipher-form/services/default-cipher-form.service.ts b/libs/vault/src/cipher-form/services/default-cipher-form.service.ts index 8e73d9edd40..1b7e86f82a7 100644 --- a/libs/vault/src/cipher-form/services/default-cipher-form.service.ts +++ b/libs/vault/src/cipher-form/services/default-cipher-form.service.ts @@ -1,6 +1,7 @@ import { inject, Injectable } from "@angular/core"; import { firstValueFrom, map } from "rxjs"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; @@ -17,6 +18,7 @@ function isSetEqual(a: Set, b: Set) { export class DefaultCipherFormService implements CipherFormService { private cipherService: CipherService = inject(CipherService); private accountService: AccountService = inject(AccountService); + private apiService: ApiService = inject(ApiService); async decryptCipher(cipher: Cipher): Promise { const activeUserId = await firstValueFrom( @@ -66,11 +68,21 @@ export class DefaultCipherFormService implements CipherFormService { // Updating a cipher with collection changes is not supported with a single request currently // First update the cipher with the original collectionIds encryptedCipher.collectionIds = config.originalCipher.collectionIds; - await this.cipherService.updateWithServer(encryptedCipher, config.admin); + await this.cipherService.updateWithServer( + encryptedCipher, + config.admin || originalCollectionIds.size === 0, + config.mode !== "clone", + ); // Then save the new collection changes separately encryptedCipher.collectionIds = cipher.collectionIds; - savedCipher = await this.cipherService.saveCollectionsWithServer(encryptedCipher); + + if (config.admin || originalCollectionIds.size === 0) { + // When using an admin config or the cipher was unassigned, update collections as an admin + savedCipher = await this.cipherService.saveCollectionsWithServerAdmin(encryptedCipher); + } else { + savedCipher = await this.cipherService.saveCollectionsWithServer(encryptedCipher); + } } // Its possible the cipher was made no longer available due to collection assignment changes diff --git a/libs/vault/src/cipher-view/cipher-view.component.html b/libs/vault/src/cipher-view/cipher-view.component.html index ad5191b0e2b..f0ebeecdf40 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.html +++ b/libs/vault/src/cipher-view/cipher-view.component.html @@ -40,6 +40,9 @@ + + + diff --git a/libs/vault/src/cipher-view/cipher-view.component.ts b/libs/vault/src/cipher-view/cipher-view.component.ts index 5d61caf52f3..597be3283e6 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.ts +++ b/libs/vault/src/cipher-view/cipher-view.component.ts @@ -21,6 +21,7 @@ import { CustomFieldV2Component } from "./custom-fields/custom-fields-v2.compone import { ItemDetailsV2Component } from "./item-details/item-details-v2.component"; import { ItemHistoryV2Component } from "./item-history/item-history-v2.component"; import { LoginCredentialsViewComponent } from "./login-credentials/login-credentials-view.component"; +import { SshKeyViewComponent } from "./sshkey-sections/sshkey-view.component"; import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-identity-sections.component"; @Component({ @@ -38,6 +39,7 @@ import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-ide ItemHistoryV2Component, CustomFieldV2Component, CardDetailsComponent, + SshKeyViewComponent, ViewIdentitySectionsComponent, LoginCredentialsViewComponent, AutofillOptionsViewComponent, @@ -95,9 +97,14 @@ export class CipherViewComponent implements OnChanges, OnDestroy { return this.cipher.login?.uris.length > 0; } + get hasSshKey() { + return this.cipher.sshKey?.privateKey; + } + async loadCipherData() { // Load collections if not provided and the cipher has collectionIds if ( + this.cipher.collectionIds && this.cipher.collectionIds.length > 0 && (!this.collections || this.collections.length === 0) ) { diff --git a/libs/vault/src/cipher-view/sshkey-sections/sshkey-view.component.html b/libs/vault/src/cipher-view/sshkey-sections/sshkey-view.component.html new file mode 100644 index 00000000000..ee5a94249c4 --- /dev/null +++ b/libs/vault/src/cipher-view/sshkey-sections/sshkey-view.component.html @@ -0,0 +1,50 @@ + + + {{ "typeSshKey" | i18n }} + + + + {{ "sshPrivateKey" | i18n }} + + + + + + {{ "sshPublicKey" | i18n }} + + + + + {{ "sshFingerprint" | i18n }} + + + + + 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 new file mode 100644 index 00000000000..7f553dbe58b --- /dev/null +++ b/libs/vault/src/cipher-view/sshkey-sections/sshkey-view.component.ts @@ -0,0 +1,35 @@ +import { CommonModule } from "@angular/common"; +import { Component, Input } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view"; +import { + CardComponent, + SectionComponent, + SectionHeaderComponent, + TypographyModule, + FormFieldModule, + IconButtonModule, +} from "@bitwarden/components"; + +import { OrgIconDirective } from "../../components/org-icon.directive"; + +@Component({ + selector: "app-sshkey-view", + templateUrl: "sshkey-view.component.html", + standalone: true, + imports: [ + CommonModule, + JslibModule, + CardComponent, + SectionComponent, + SectionHeaderComponent, + TypographyModule, + OrgIconDirective, + FormFieldModule, + IconButtonModule, + ], +}) +export class SshKeyViewComponent { + @Input() sshKey: SshKeyView; +} diff --git a/libs/vault/src/components/copy-cipher-field.directive.ts b/libs/vault/src/components/copy-cipher-field.directive.ts index 7d842c36bfe..e03419815bf 100644 --- a/libs/vault/src/components/copy-cipher-field.directive.ts +++ b/libs/vault/src/components/copy-cipher-field.directive.ts @@ -91,6 +91,12 @@ export class CopyCipherFieldDirective implements OnChanges { return this.cipher.identity?.fullAddressForCopy; case "secureNote": return this.cipher.notes; + case "privateKey": + return this.cipher.sshKey?.privateKey; + case "publicKey": + return this.cipher.sshKey?.publicKey; + case "keyFingerprint": + return this.cipher.sshKey?.keyFingerprint; default: return null; } diff --git a/libs/vault/src/components/password-history-view/password-history-view.component.html b/libs/vault/src/components/password-history-view/password-history-view.component.html index 44b7fea5f75..459c679945c 100644 --- a/libs/vault/src/components/password-history-view/password-history-view.component.html +++ b/libs/vault/src/components/password-history-view/password-history-view.component.html @@ -15,10 +15,10 @@ bitIconButton="bwi-clone" [appA11yTitle]="'copyPassword' | i18n" appStopClick - (click)="copy(h.password)" - > - - + [appCopyClick]="h.password" + [valueLabel]="'password' | i18n" + showToast + > diff --git a/libs/vault/src/components/password-history-view/password-history-view.component.spec.ts b/libs/vault/src/components/password-history-view/password-history-view.component.spec.ts index 8772a245821..3900681f230 100644 --- a/libs/vault/src/components/password-history-view/password-history-view.component.spec.ts +++ b/libs/vault/src/components/password-history-view/password-history-view.component.spec.ts @@ -3,14 +3,13 @@ import { By } from "@angular/platform-browser"; import { BehaviorSubject } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { ColorPasswordModule, ItemModule, ToastService } from "@bitwarden/components"; +import { ColorPasswordModule, ItemModule } from "@bitwarden/components"; import { ColorPasswordComponent } from "@bitwarden/components/src/color-password/color-password.component"; import { PasswordHistoryViewComponent } from "./password-history-view.component"; @@ -25,8 +24,6 @@ describe("PasswordHistoryViewComponent", () => { organizationId: "222-444-555", } as CipherView; - const copyToClipboard = jest.fn(); - const showToast = jest.fn(); const activeAccount$ = new BehaviorSubject<{ id: string }>({ id: "666-444-444" }); const mockCipherService = { get: jest.fn().mockResolvedValue({ decrypt: jest.fn().mockResolvedValue(mockCipher) }), @@ -36,17 +33,13 @@ describe("PasswordHistoryViewComponent", () => { beforeEach(async () => { mockCipherService.get.mockClear(); mockCipherService.getKeyForCipherKeyDecryption.mockClear(); - copyToClipboard.mockClear(); - showToast.mockClear(); await TestBed.configureTestingModule({ imports: [ItemModule, ColorPasswordModule, JslibModule], providers: [ - { provide: WINDOW, useValue: window }, { provide: CipherService, useValue: mockCipherService }, - { provide: PlatformUtilsService, useValue: { copyToClipboard } }, + { provide: PlatformUtilsService }, { provide: AccountService, useValue: { activeAccount$ } }, - { provide: ToastService, useValue: { showToast } }, { provide: I18nService, useValue: { t: (key: string) => key } }, ], }).compileComponents(); @@ -80,18 +73,5 @@ describe("PasswordHistoryViewComponent", () => { "bad-password-2", ]); }); - - it("copies a password", () => { - const copyButton = fixture.debugElement.query(By.css("button")); - - copyButton.nativeElement.click(); - - expect(copyToClipboard).toHaveBeenCalledWith("bad-password-1", { window: window }); - expect(showToast).toHaveBeenCalledWith({ - message: "passwordCopied", - title: "", - variant: "info", - }); - }); }); }); 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 5e858af7275..a0f0aa6b35b 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 @@ -1,21 +1,14 @@ import { CommonModule } from "@angular/common"; -import { OnInit, Inject, Component, Input } from "@angular/core"; +import { OnInit, Component, Input } from "@angular/core"; import { firstValueFrom, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { PasswordHistoryView } from "@bitwarden/common/vault/models/view/password-history.view"; -import { - ToastService, - ItemModule, - ColorPasswordModule, - IconButtonModule, -} from "@bitwarden/components"; +import { ItemModule, ColorPasswordModule, IconButtonModule } from "@bitwarden/components"; @Component({ selector: "vault-password-history-view", @@ -33,29 +26,15 @@ export class PasswordHistoryViewComponent implements OnInit { history: PasswordHistoryView[] = []; constructor( - @Inject(WINDOW) private win: Window, protected cipherService: CipherService, - protected platformUtilsService: PlatformUtilsService, protected i18nService: I18nService, protected accountService: AccountService, - protected toastService: ToastService, ) {} async ngOnInit() { await this.init(); } - /** Copies a password to the clipboard. */ - copy(password: string) { - const copyOptions = this.win != null ? { window: this.win } : undefined; - this.platformUtilsService.copyToClipboard(password, copyOptions); - this.toastService.showToast({ - variant: "info", - title: "", - message: this.i18nService.t("passwordCopied"), - }); - } - /** Retrieve the password history for the given cipher */ protected async init() { const cipher = await this.cipherService.get(this.cipherId); diff --git a/libs/vault/src/services/copy-cipher-field.service.ts b/libs/vault/src/services/copy-cipher-field.service.ts index 4767ae01bca..1867b10cd17 100644 --- a/libs/vault/src/services/copy-cipher-field.service.ts +++ b/libs/vault/src/services/copy-cipher-field.service.ts @@ -25,7 +25,10 @@ export type CopyAction = | "phone" | "address" | "secureNote" - | "hiddenField"; + | "hiddenField" + | "privateKey" + | "publicKey" + | "keyFingerprint"; type CopyActionInfo = { /** @@ -62,6 +65,9 @@ const CopyActions: Record = { phone: { typeI18nKey: "phone", protected: true }, address: { typeI18nKey: "address", protected: true }, secureNote: { typeI18nKey: "note", protected: true }, + privateKey: { typeI18nKey: "sshPrivateKey", protected: true }, + publicKey: { typeI18nKey: "sshPublicKey", protected: true }, + keyFingerprint: { typeI18nKey: "sshFingerprint", protected: true }, hiddenField: { typeI18nKey: "value", protected: true, diff --git a/package-lock.json b/package-lock.json index ab1c7d90655..e71e8c387d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,7 @@ "bufferutil": "4.0.8", "chalk": "4.1.2", "commander": "11.1.0", - "core-js": "3.36.1", + "core-js": "3.39.0", "form-data": "4.0.0", "https-proxy-agent": "7.0.5", "inquirer": "8.2.6", @@ -68,7 +68,7 @@ "qrious": "4.0.2", "rxjs": "7.8.1", "tabbable": "6.2.0", - "tldts": "6.1.58", + "tldts": "6.1.60", "utf-8-validate": "6.0.5", "zone.js": "0.14.10", "zxcvbn": "4.4.2" @@ -134,7 +134,7 @@ "css-loader": "7.1.2", "electron": "32.1.1", "electron-builder": "24.13.3", - "electron-log": "5.0.1", + "electron-log": "5.2.2", "electron-reload": "2.0.0-alpha.1", "electron-store": "8.2.0", "electron-updater": "6.3.9", @@ -151,7 +151,7 @@ "gulp-json-editor": "2.6.0", "gulp-replace": "1.1.4", "gulp-zip": "6.0.0", - "html-loader": "5.0.0", + "html-loader": "5.1.0", "html-webpack-injector": "1.1.4", "html-webpack-plugin": "5.6.3", "husky": "9.1.4", @@ -162,7 +162,7 @@ "lint-staged": "15.2.8", "mini-css-extract-plugin": "2.9.1", "node-ipc": "9.2.1", - "postcss": "8.4.38", + "postcss": "8.4.47", "postcss-loader": "8.1.1", "prettier": "3.3.3", "prettier-plugin-tailwindcss": "0.6.8", @@ -182,7 +182,7 @@ "url": "0.11.4", "util": "0.12.5", "wait-on": "8.0.1", - "webpack": "5.94.0", + "webpack": "5.96.1", "webpack-cli": "5.1.4", "webpack-dev-server": "5.0.4", "webpack-node-externals": "3.0.0" @@ -225,7 +225,7 @@ "papaparse": "5.4.1", "proper-lockfile": "4.1.2", "rxjs": "7.8.1", - "tldts": "6.1.58", + "tldts": "6.1.60", "zxcvbn": "4.4.2" }, "bin": { @@ -924,6 +924,30 @@ } } }, + "node_modules/@angular-devkit/build-angular/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/@angular-devkit/build-angular/node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -1030,6 +1054,13 @@ "node": ">= 10" } }, + "node_modules/@angular-devkit/build-angular/node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, "node_modules/@angular-devkit/build-angular/node_modules/mini-css-extract-plugin": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.8.1.tgz", @@ -1226,6 +1257,53 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@angular-devkit/build-angular/node_modules/webpack": { + "version": "5.94.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", + "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.5", + "@webassemblyjs/ast": "^1.12.1", + "@webassemblyjs/wasm-edit": "^1.12.1", + "@webassemblyjs/wasm-parser": "^1.12.1", + "acorn": "^8.7.1", + "acorn-import-attributes": "^1.9.5", + "browserslist": "^4.21.10", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.1", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.2.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.10", + "watchpack": "^2.4.1", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, "node_modules/@angular-devkit/build-angular/node_modules/webpack-dev-server": { "version": "4.15.1", "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.1.tgz", @@ -1310,6 +1388,73 @@ "webpack": "^4.0.0 || ^5.0.0" } }, + "node_modules/@angular-devkit/build-angular/node_modules/webpack/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/webpack/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/webpack/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular-devkit/build-angular/node_modules/webpack/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/webpack/node_modules/watchpack": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", + "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/@angular-devkit/core": { "version": "18.2.11", "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-18.2.11.tgz", @@ -9203,6 +9348,28 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -14735,9 +14902,9 @@ } }, "node_modules/core-js": { - "version": "3.36.1", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.36.1.tgz", - "integrity": "sha512-BTvUrwxVBezj5SZ3f10ImnX2oRByMxql3EimVqMysepbC9EeMUOpLwdy6Eoili2x6E4kf+ZUB5k/+Jv55alPfA==", + "version": "3.39.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz", + "integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==", "hasInstallScript": true, "license": "MIT", "funding": { @@ -16272,9 +16439,9 @@ } }, "node_modules/electron-log": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.0.1.tgz", - "integrity": "sha512-x4wnwHg00h/onWQgjmvcdLV7Mrd9TZjxNs8LmXVpqvANDf4FsSs5wLlzOykWLcaFzR3+5hdVEQ8ctmrUxgHlPA==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.2.2.tgz", + "integrity": "sha512-fgvx6srjIHDowJD8WAAjoAXmiTyOz6JnGQoxOtk1mXw7o4S+HutuPHLCsk24xTXqWZgy4uO63NbedG+oEvldLw==", "dev": true, "license": "MIT", "engines": { @@ -21127,9 +21294,9 @@ "peer": true }, "node_modules/html-loader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/html-loader/-/html-loader-5.0.0.tgz", - "integrity": "sha512-puaGKdjdVVIFRtgIC2n5dt5bt0N5j6heXlAQZ4Do1MLjHmOT1gCE1Ogg7XZNeJlnOVHHsrZKGs5dfh+XwZ3XPw==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/html-loader/-/html-loader-5.1.0.tgz", + "integrity": "sha512-Jb3xwDbsm0W3qlXrCZwcYqYGnYz55hb6aoKQTlzyZPXsPpi6tHXzAfqalecglMQgNvtEfxrCQPaKT90Irt5XDA==", "dev": true, "license": "MIT", "dependencies": { @@ -30889,9 +31056,9 @@ } }, "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "dev": true, "funding": [ { @@ -30910,8 +31077,8 @@ "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -35926,21 +36093,21 @@ } }, "node_modules/tldts": { - "version": "6.1.58", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.58.tgz", - "integrity": "sha512-MQJrJhjHOYGYb8DobR6Y4AdDbd4TYkyQ+KBDVc5ODzs1cbrvPpfN1IemYi9jfipJ/vR1YWvrDli0hg1y19VRoA==", + "version": "6.1.60", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.60.tgz", + "integrity": "sha512-TYVHm7G9NCnhgqOsFalbX6MG1Po5F4efF+tLfoeiOGQq48Oqgwcgz8upY2R1BHWa4aDrj28RYx0dkYJ63qCFMg==", "license": "MIT", "dependencies": { - "tldts-core": "^6.1.58" + "tldts-core": "^6.1.60" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "6.1.58", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.58.tgz", - "integrity": "sha512-dR936xmhBm7AeqHIhCWwK765gZ7dFyL+IqLSFAjJbFlUXGMLCb8i2PzlzaOuWBuplBTaBYseSb565nk/ZEM0Bg==", + "version": "6.1.60", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.60.tgz", + "integrity": "sha512-XHjoxak8SFQnHnmYHb3PcnW5TZ+9ErLZemZei3azuIRhQLw4IExsVbL3VZJdHcLeNaXq6NqawgpDPpjBOg4B5g==", "license": "MIT" }, "node_modules/tmp": { @@ -38412,19 +38579,19 @@ } }, "node_modules/webpack": { - "version": "5.94.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", - "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", + "version": "5.96.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.96.1.tgz", + "integrity": "sha512-l2LlBSvVZGhL4ZrPwyr8+37AunkcYj5qh8o6u2/2rzoPc8gxFJkLj1WxNgooi9pnoc06jh0BjuXnamM4qlujZA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "^1.0.5", + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.6", "@webassemblyjs/ast": "^1.12.1", "@webassemblyjs/wasm-edit": "^1.12.1", "@webassemblyjs/wasm-parser": "^1.12.1", - "acorn": "^8.7.1", - "acorn-import-attributes": "^1.9.5", - "browserslist": "^4.21.10", + "acorn": "^8.14.0", + "browserslist": "^4.24.0", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.17.1", "es-module-lexer": "^1.2.1", @@ -38853,6 +39020,39 @@ "ajv": "^6.9.1" } }, + "node_modules/webpack/node_modules/browserslist": { + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", + "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001669", + "electron-to-chromium": "^1.5.41", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.1" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/webpack/node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", diff --git a/package.json b/package.json index 1018a1bd262..282a63f2351 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,7 @@ "css-loader": "7.1.2", "electron": "32.1.1", "electron-builder": "24.13.3", - "electron-log": "5.0.1", + "electron-log": "5.2.2", "electron-reload": "2.0.0-alpha.1", "electron-store": "8.2.0", "electron-updater": "6.3.9", @@ -112,7 +112,7 @@ "gulp-json-editor": "2.6.0", "gulp-replace": "1.1.4", "gulp-zip": "6.0.0", - "html-loader": "5.0.0", + "html-loader": "5.1.0", "html-webpack-injector": "1.1.4", "html-webpack-plugin": "5.6.3", "husky": "9.1.4", @@ -123,7 +123,7 @@ "lint-staged": "15.2.8", "mini-css-extract-plugin": "2.9.1", "node-ipc": "9.2.1", - "postcss": "8.4.38", + "postcss": "8.4.47", "postcss-loader": "8.1.1", "prettier": "3.3.3", "prettier-plugin-tailwindcss": "0.6.8", @@ -143,7 +143,7 @@ "url": "0.11.4", "util": "0.12.5", "wait-on": "8.0.1", - "webpack": "5.94.0", + "webpack": "5.96.1", "webpack-cli": "5.1.4", "webpack-dev-server": "5.0.4", "webpack-node-externals": "3.0.0" @@ -174,7 +174,7 @@ "bufferutil": "4.0.8", "chalk": "4.1.2", "commander": "11.1.0", - "core-js": "3.36.1", + "core-js": "3.39.0", "form-data": "4.0.0", "https-proxy-agent": "7.0.5", "inquirer": "8.2.6", @@ -202,7 +202,7 @@ "qrious": "4.0.2", "rxjs": "7.8.1", "tabbable": "6.2.0", - "tldts": "6.1.58", + "tldts": "6.1.60", "utf-8-validate": "6.0.5", "zone.js": "0.14.10", "zxcvbn": "4.4.2"
diff --git a/apps/web/src/app/billing/shared/payment-method.component.ts b/apps/web/src/app/billing/shared/payment-method.component.ts index 06acf2142a5..98e6efcd8bd 100644 --- a/apps/web/src/app/billing/shared/payment-method.component.ts +++ b/apps/web/src/app/billing/shared/payment-method.component.ts @@ -1,10 +1,13 @@ -import { Component, OnInit, ViewChild } from "@angular/core"; +import { Location } from "@angular/common"; +import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { FormBuilder, FormControl, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; import { lastValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { PaymentMethodType } from "@bitwarden/common/billing/enums"; import { BillingPaymentResponse } from "@bitwarden/common/billing/models/response/billing-payment.response"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; @@ -13,8 +16,12 @@ import { VerifyBankRequest } from "@bitwarden/common/models/request/verify-bank. import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; import { DialogService, ToastService } from "@bitwarden/components"; +import { FreeTrial } from "../../core/types/free-trial"; +import { TrialFlowService } from "../services/trial-flow.service"; + import { AddCreditDialogResult, openAddCreditDialog } from "./add-credit-dialog.component"; import { AdjustPaymentDialogResult, @@ -26,7 +33,7 @@ import { TaxInfoComponent } from "./tax-info.component"; templateUrl: "payment-method.component.html", }) // eslint-disable-next-line rxjs-angular/prefer-takeuntil -export class PaymentMethodComponent implements OnInit { +export class PaymentMethodComponent implements OnInit, OnDestroy { @ViewChild(TaxInfoComponent) taxInfo: TaxInfoComponent; loading = false; @@ -37,6 +44,7 @@ export class PaymentMethodComponent implements OnInit { paymentMethodType = PaymentMethodType; organizationId: string; isUnpaid = false; + organization: Organization; verifyBankForm = this.formBuilder.group({ amount1: new FormControl(null, [ @@ -52,6 +60,8 @@ export class PaymentMethodComponent implements OnInit { }); taxForm = this.formBuilder.group({}); + launchPaymentModalAutomatically = false; + protected freeTrialData: FreeTrial; constructor( protected apiService: ApiService, @@ -59,12 +69,30 @@ export class PaymentMethodComponent implements OnInit { protected i18nService: I18nService, protected platformUtilsService: PlatformUtilsService, private router: Router, + private location: Location, private logService: LogService, private route: ActivatedRoute, private formBuilder: FormBuilder, private dialogService: DialogService, private toastService: ToastService, - ) {} + private trialFlowService: TrialFlowService, + private organizationService: OrganizationService, + protected syncService: SyncService, + ) { + const state = this.router.getCurrentNavigation()?.extras?.state; + // incase the above state is undefined or null we use redundantState + const redundantState: any = location.getState(); + if (state && Object.prototype.hasOwnProperty.call(state, "launchPaymentModalAutomatically")) { + this.launchPaymentModalAutomatically = state.launchPaymentModalAutomatically; + } else if ( + redundantState && + Object.prototype.hasOwnProperty.call(redundantState, "launchPaymentModalAutomatically") + ) { + this.launchPaymentModalAutomatically = redundantState.launchPaymentModalAutomatically; + } else { + this.launchPaymentModalAutomatically = false; + } + } async ngOnInit() { // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe @@ -88,27 +116,37 @@ export class PaymentMethodComponent implements OnInit { return; } this.loading = true; - if (this.forOrganization) { const billingPromise = this.organizationApiService.getBilling(this.organizationId); const organizationSubscriptionPromise = this.organizationApiService.getSubscription( this.organizationId, ); + const organizationPromise = this.organizationService.get(this.organizationId); - [this.billing, this.org] = await Promise.all([ + [this.billing, this.org, this.organization] = await Promise.all([ billingPromise, organizationSubscriptionPromise, + organizationPromise, ]); + this.determineOrgsWithUpcomingPaymentIssues(); } else { const billingPromise = this.apiService.getUserBillingPayment(); const subPromise = this.apiService.getUserSubscription(); [this.billing, this.sub] = await Promise.all([billingPromise, subPromise]); } - this.isUnpaid = this.subscription?.status === "unpaid" ?? false; - this.loading = false; + // If the flag `launchPaymentModalAutomatically` is set to true, + // we schedule a timeout (delay of 800ms) to automatically launch the payment modal. + // This delay ensures that any prior UI/rendering operations complete before triggering the modal. + if (this.launchPaymentModalAutomatically) { + window.setTimeout(async () => { + await this.changePayment(); + this.launchPaymentModalAutomatically = false; + this.location.replaceState(this.location.path(), "", {}); + }, 800); + } }; addCredit = async () => { @@ -132,6 +170,11 @@ export class PaymentMethodComponent implements OnInit { }); const result = await lastValueFrom(dialogRef.closed); if (result === AdjustPaymentDialogResult.Adjusted) { + this.location.replaceState(this.location.path(), "", {}); + if (this.launchPaymentModalAutomatically && !this.organization.enabled) { + await this.syncService.fullSync(true); + } + this.launchPaymentModalAutomatically = false; await this.load(); } }; @@ -162,6 +205,14 @@ export class PaymentMethodComponent implements OnInit { }); }; + determineOrgsWithUpcomingPaymentIssues() { + this.freeTrialData = this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues( + this.organization, + this.org, + this.billing?.paymentSource, + ); + } + get isCreditBalance() { return this.billing == null || this.billing.balance <= 0; } @@ -203,4 +254,8 @@ export class PaymentMethodComponent implements OnInit { get subscription() { return this.sub?.subscription ?? this.org?.subscription ?? null; } + + ngOnDestroy(): void { + this.launchPaymentModalAutomatically = false; + } } diff --git a/apps/web/src/app/core/event.service.ts b/apps/web/src/app/core/event.service.ts index 0bfe1d0dc89..be6a62443d3 100644 --- a/apps/web/src/app/core/event.service.ts +++ b/apps/web/src/app/core/event.service.ts @@ -330,6 +330,20 @@ export class EventService { this.getShortId(ev.organizationUserId), ); break; + case EventType.OrganizationUser_Deleted: + msg = this.i18nService.t("deletedUserId", this.formatOrgUserId(ev)); + humanReadableMsg = this.i18nService.t( + "deletedUserId", + this.getShortId(ev.organizationUserId), + ); + break; + case EventType.OrganizationUser_Left: + msg = this.i18nService.t("userLeftOrganization", this.formatOrgUserId(ev)); + humanReadableMsg = this.i18nService.t( + "userLeftOrganization", + this.getShortId(ev.organizationUserId), + ); + break; // Org case EventType.Organization_Updated: msg = humanReadableMsg = this.i18nService.t("editedOrgSettings"); diff --git a/apps/web/src/app/core/types/free-trial.ts b/apps/web/src/app/core/types/free-trial.ts new file mode 100644 index 00000000000..ee5fb921621 --- /dev/null +++ b/apps/web/src/app/core/types/free-trial.ts @@ -0,0 +1,7 @@ +export type FreeTrial = { + remainingDays: number; + message: string; + shownBanner: boolean; + organizationId: string; + organizationName: string; +}; 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 1dd03d03230..f34d32f5983 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,6 +22,7 @@ [route]="['../', org.id]" (mainContentClicked)="toggle()" [routerLinkActiveOptions]="{ exact: true }" + (click)="handleUnpaidSubscription(org)" > - + {{ "application" | i18n }} {{ "atRiskPasswords" | i18n }} {{ "totalPasswords" | i18n }} @@ -78,7 +79,7 @@ - + - + {{ "criticalApplicationsWithCount" | i18n: criticalApps.length }} diff --git a/apps/web/src/app/tools/risk-insights/risk-insights.component.ts b/apps/web/src/app/tools/risk-insights/risk-insights.component.ts index 43d6da70e96..1c6a36b4454 100644 --- a/apps/web/src/app/tools/risk-insights/risk-insights.component.ts +++ b/apps/web/src/app/tools/risk-insights/risk-insights.component.ts @@ -1,9 +1,11 @@ import { CommonModule } from "@angular/common"; -import { Component } from "@angular/core"; +import { Component, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { AsyncActionsModule, ButtonModule, TabsModule } from "@bitwarden/components"; import { HeaderModule } from "../../layouts/header/header.module"; @@ -39,9 +41,10 @@ export enum RiskInsightsTabType { TabsModule, ], }) -export class RiskInsightsComponent { +export class RiskInsightsComponent implements OnInit { tabIndex: RiskInsightsTabType; dataLastUpdated = new Date(); + isCritialAppsFeatureEnabled = false; apps: any[] = []; criticalApps: any[] = []; @@ -65,9 +68,16 @@ export class RiskInsightsComponent { }); }; + async ngOnInit() { + this.isCritialAppsFeatureEnabled = await this.configService.getFeatureFlag( + FeatureFlag.CriticalApps, + ); + } + constructor( protected route: ActivatedRoute, private router: Router, + private configService: ConfigService, ) { route.queryParams.pipe(takeUntilDestroyed()).subscribe(({ tabIndex }) => { this.tabIndex = !isNaN(tabIndex) ? tabIndex : RiskInsightsTabType.AllApps; 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 ae2cf88fd1f..df575cc525f 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 @@ -6,6 +6,7 @@ import { firstValueFrom, Observable, Subject } from "rxjs"; import { map } from "rxjs/operators"; import { CollectionView } 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 { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; @@ -17,6 +18,8 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { @@ -231,6 +234,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { private billingAccountProfileStateService: BillingAccountProfileStateService, private premiumUpgradeService: PremiumUpgradePromptService, private cipherAuthorizationService: CipherAuthorizationService, + private apiService: ApiService, ) { this.updateTitle(); } @@ -278,7 +282,20 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { if (this._originalFormMode === "add" || this._originalFormMode === "clone") { this.formConfig.mode = "edit"; } - this.formConfig.originalCipher = await this.cipherService.get(cipherView.id); + + let cipher: Cipher; + + // When the form config is used within the Admin Console, retrieve the cipher from the admin endpoint + if (this.formConfig.isAdminConsole) { + const cipherResponse = await this.apiService.getCipherAdmin(cipherView.id); + const cipherData = new CipherData(cipherResponse); + cipher = new Cipher(cipherData); + } else { + cipher = await this.cipherService.get(cipherView.id); + } + + // Store the updated cipher so any following edits use the most up to date cipher + this.formConfig.originalCipher = cipher; this._cipherModified = true; await this.changeMode("view"); } @@ -449,7 +466,14 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { * Helper method to delete cipher. */ private async deleteCipher(): Promise { - const asAdmin = this.organization?.canEditAllCiphers; + const cipherIsUnassigned = + !this.cipher.collectionIds || this.cipher.collectionIds?.length === 0; + + // Delete the cipher as an admin when: + // - the organization allows for owners/admins to manage all collections/items + // - the cipher is unassigned + const asAdmin = this.organization?.canEditAllCiphers || cipherIsUnassigned; + if (this.cipher.isDeleted) { await this.cipherService.deleteWithServer(this.cipher.id, asAdmin); } else { diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.html b/apps/web/src/app/vault/components/vault-items/vault-items.component.html index d6bcd76903b..653d05ef129 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.html @@ -16,13 +16,39 @@ "all" | i18n }} - {{ "name" | i18n }} + + + {{ "name" | i18n }} + + + + {{ "name" | i18n }} + {{ "owner" | i18n }} {{ "collections" | i18n }} - {{ "groups" | i18n }} - + + {{ "groups" | i18n }} + + {{ "permission" | i18n }} diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts index 71a97f1ff44..9f19a0319a5 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -1,13 +1,17 @@ import { SelectionModel } from "@angular/cdk/collections"; import { Component, EventEmitter, Input, Output } from "@angular/core"; -import { CollectionView, Unassigned } from "@bitwarden/admin-console/common"; +import { CollectionView, Unassigned, CollectionAdminView } from "@bitwarden/admin-console/common"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { TableDataSource } from "@bitwarden/components"; +import { SortDirection, TableDataSource } from "@bitwarden/components"; import { GroupView } from "../../../admin-console/organizations/core"; +import { + CollectionPermission, + convertToPermission, +} from "./../../../admin-console/organizations/shared/components/access-selector/access-selector.models"; import { VaultItem } from "./vault-item"; import { VaultItemEvent } from "./vault-item-event"; @@ -17,6 +21,8 @@ export const RowHeightClass = `tw-h-[65px]`; const MaxSelectionCount = 500; +type ItemPermission = CollectionPermission | "NoAccess"; + @Component({ selector: "app-vault-items", templateUrl: "vault-items.component.html", @@ -333,6 +339,119 @@ export class VaultItemsComponent { return (canEditOrManageAllCiphers || this.allCiphersHaveEditAccess()) && collectionNotSelected; } + /** + * Sorts VaultItems, grouping collections before ciphers, and sorting each group alphabetically by name. + */ + protected sortByName = (a: VaultItem, b: VaultItem, direction: SortDirection) => { + // Collections before ciphers + const collectionCompare = this.prioritizeCollections(a, b, direction); + if (collectionCompare !== 0) { + return collectionCompare; + } + + return this.compareNames(a, b); + }; + + /** + * Sorts VaultItems based on group names + */ + protected sortByGroups = (a: VaultItem, b: VaultItem, direction: SortDirection) => { + if ( + !(a.collection instanceof CollectionAdminView) && + !(b.collection instanceof CollectionAdminView) + ) { + return 0; + } + + const getFirstGroupName = (collection: CollectionAdminView): string => { + if (collection.groups.length > 0) { + return collection.groups.map((group) => this.getGroupName(group.id) || "").sort()[0]; + } + return null; + }; + + // Collections before ciphers + const collectionCompare = this.prioritizeCollections(a, b, direction); + if (collectionCompare !== 0) { + return collectionCompare; + } + + const aGroupName = getFirstGroupName(a.collection as CollectionAdminView); + const bGroupName = getFirstGroupName(b.collection as CollectionAdminView); + + // Collections with groups come before collections without groups. + // If a collection has no groups, getFirstGroupName returns null. + if (aGroupName === null) { + return 1; + } + + if (bGroupName === null) { + return -1; + } + + return aGroupName.localeCompare(bGroupName); + }; + + /** + * Sorts VaultItems based on their permissions, with higher permissions taking precedence. + * If permissions are equal, it falls back to sorting by name. + */ + protected sortByPermissions = (a: VaultItem, b: VaultItem, direction: SortDirection) => { + const getPermissionPriority = (item: VaultItem): number => { + const permission = item.collection + ? this.getCollectionPermission(item.collection) + : this.getCipherPermission(item.cipher); + + const priorityMap = { + [CollectionPermission.Manage]: 5, + [CollectionPermission.Edit]: 4, + [CollectionPermission.EditExceptPass]: 3, + [CollectionPermission.View]: 2, + [CollectionPermission.ViewExceptPass]: 1, + NoAccess: 0, + }; + + return priorityMap[permission] ?? -1; + }; + + // Collections before ciphers + const collectionCompare = this.prioritizeCollections(a, b, direction); + if (collectionCompare !== 0) { + return collectionCompare; + } + + const priorityA = getPermissionPriority(a); + const priorityB = getPermissionPriority(b); + + // Higher priority first + if (priorityA !== priorityB) { + return priorityA - priorityB; + } + + return this.compareNames(a, b); + }; + + private compareNames(a: VaultItem, b: VaultItem): number { + const getName = (item: VaultItem) => item.collection?.name || item.cipher?.name; + return getName(a).localeCompare(getName(b)); + } + + /** + * Sorts VaultItems by prioritizing collections over ciphers. + * Collections are always placed before ciphers, regardless of the sorting direction. + */ + private prioritizeCollections(a: VaultItem, b: VaultItem, direction: SortDirection): number { + if (a.collection && !b.collection) { + return direction === "asc" ? -1 : 1; + } + + if (!a.collection && b.collection) { + return direction === "asc" ? 1 : -1; + } + + return 0; + } + private hasPersonalItems(): boolean { return this.selection.selected.some(({ cipher }) => cipher?.organizationId === null); } @@ -346,4 +465,58 @@ export class VaultItemsComponent { private getUniqueOrganizationIds(): Set { return new Set(this.selection.selected.flatMap((i) => i.cipher?.organizationId ?? [])); } + + private getGroupName(groupId: string): string | undefined { + return this.allGroups.find((g) => g.id === groupId)?.name; + } + + private getCollectionPermission(collection: CollectionView): ItemPermission { + const organization = this.allOrganizations.find((o) => o.id === collection.organizationId); + + if (collection.id == Unassigned && organization?.canEditUnassignedCiphers) { + return CollectionPermission.Edit; + } + + if (collection.assigned) { + return convertToPermission(collection); + } + + return "NoAccess"; + } + + private getCipherPermission(cipher: CipherView): ItemPermission { + if (!cipher.organizationId || cipher.collectionIds.length === 0) { + return CollectionPermission.Manage; + } + + const filteredCollections = this.allCollections?.filter((collection) => { + if (collection.assigned) { + return cipher.collectionIds.find((id) => { + if (collection.id === id) { + return collection; + } + }); + } + }); + + if (filteredCollections?.length === 1) { + return convertToPermission(filteredCollections[0]); + } + + if (filteredCollections?.length > 0) { + const permissions = filteredCollections.map((collection) => convertToPermission(collection)); + + const orderedPermissions = [ + CollectionPermission.Manage, + CollectionPermission.Edit, + CollectionPermission.EditExceptPass, + CollectionPermission.View, + CollectionPermission.ViewExceptPass, + ]; + + return orderedPermissions.find((perm) => permissions.includes(perm)); + } + + return "NoAccess"; + } } diff --git a/apps/web/src/app/vault/individual-vault/add-edit.component.html b/apps/web/src/app/vault/individual-vault/add-edit.component.html index 855b5dac489..01ac60fc7e6 100644 --- a/apps/web/src/app/vault/individual-vault/add-edit.component.html +++ b/apps/web/src/app/vault/individual-vault/add-edit.component.html @@ -851,6 +851,99 @@ + + + + + {{ "sshKeyPrivateKey" | i18n }} + + + + + + + + + + + + + + {{ "sshKeyPublicKey" | i18n }} + + + + + + + + + + + {{ "sshKeyFingerprint" | i18n }} + + + + + + + + + + + {{ "notes" | i18n }} + {{ freeTrialMessage(organization) }} + + {{ "routeToPaymentMethodTrigger" | i18n }} + + + ; VisibleVaultBanner = VisibleVaultBanner; + @Input() organizationsPaymentStatus: FreeTrial[] = []; - constructor(private vaultBannerService: VaultBannersService) { + constructor( + private vaultBannerService: VaultBannersService, + private router: Router, + private i18nService: I18nService, + ) { this.premiumBannerVisible$ = this.vaultBannerService.shouldShowPremiumBanner$; } @@ -34,6 +42,17 @@ export class VaultBannersComponent implements OnInit { await this.determineVisibleBanners(); } + async navigateToPaymentMethod(organizationId: string): Promise { + const navigationExtras = { + state: { launchPaymentModalAutomatically: true }, + }; + + await this.router.navigate( + ["organizations", organizationId, "billing", "payment-method"], + navigationExtras, + ); + } + /** Determine which banners should be present */ private async determineVisibleBanners(): Promise { const showBrowserOutdated = await this.vaultBannerService.shouldShowUpdateBrowserBanner(); @@ -46,4 +65,22 @@ export class VaultBannersComponent implements OnInit { showLowKdf ? VisibleVaultBanner.KDFSettings : null, ].filter(Boolean); // remove all falsy values, i.e. null } + + freeTrialMessage(organization: FreeTrial) { + if (organization.remainingDays >= 2) { + return this.i18nService.t( + "freeTrialEndPromptAboveTwoDays", + organization.organizationName, + organization.remainingDays.toString(), + ); + } else if (organization.remainingDays === 1) { + return this.i18nService.t("freeTrialEndPromptForOneDay", organization.organizationName); + } else { + return this.i18nService.t("freeTrialEndPromptForLessThanADay", organization.organizationName); + } + } + + trackBy(index: number) { + return index; + } } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts index 3b7db72a09d..57eb9b1bdd9 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts @@ -1,5 +1,5 @@ import { Component, Inject, OnDestroy, OnInit } from "@angular/core"; -import { combineLatest, map, Observable, Subject, takeUntil } from "rxjs"; +import { combineLatest, map, Observable, of, Subject, switchMap, takeUntil } from "rxjs"; import { OrganizationUserApiService, @@ -8,11 +8,14 @@ import { import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -53,6 +56,8 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy { private resetPasswordService: OrganizationUserResetPasswordService, private userVerificationService: UserVerificationService, private toastService: ToastService, + private configService: ConfigService, + private organizationService: OrganizationService, ) {} async ngOnInit() { @@ -60,23 +65,39 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy { map((policies) => policies.filter((policy) => policy.type === PolicyType.ResetPassword)), ); + const managingOrg$ = this.configService + .getFeatureFlag$(FeatureFlag.AccountDeprovisioning) + .pipe( + switchMap((isAccountDeprovisioningEnabled) => + isAccountDeprovisioningEnabled + ? this.organizationService.organizations$.pipe( + map((organizations) => + organizations.find((o) => o.userIsManagedByOrganization === true), + ), + ) + : of(null), + ), + ); + combineLatest([ this.organization$, resetPasswordPolicies$, this.userDecryptionOptionsService.userDecryptionOptions$, + managingOrg$, ]) .pipe(takeUntil(this.destroy$)) - .subscribe(([organization, resetPasswordPolicies, decryptionOptions]) => { + .subscribe(([organization, resetPasswordPolicies, decryptionOptions, managingOrg]) => { this.organization = organization; this.resetPasswordPolicy = resetPasswordPolicies.find( (p) => p.organizationId === organization.id, ); - // A user can leave an organization if they are NOT using TDE and Key Connector, or they have a master password. + // A user can leave an organization if they are NOT a managed user and they are NOT using TDE and Key Connector, or they have a master password. this.showLeaveOrgOption = - (decryptionOptions.trustedDeviceOption == undefined && + managingOrg?.id !== organization.id && + ((decryptionOptions.trustedDeviceOption == undefined && decryptionOptions.keyConnectorOption == undefined) || - decryptionOptions.hasMasterPassword; + decryptionOptions.hasMasterPassword); // Hide the 3 dot menu if the user has no available actions this.hideMenu = diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts index c9066ae66d2..09a7356c452 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts @@ -1,12 +1,16 @@ -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; +import { Component, EventEmitter, inject, Input, OnDestroy, OnInit, Output } from "@angular/core"; +import { Router } from "@angular/router"; import { firstValueFrom, Subject } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { DialogService } from "@bitwarden/components"; import { VaultFilterService } from "../services/abstractions/vault-filter.service"; import { @@ -40,7 +44,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { isLoaded = false; protected destroy$: Subject = new Subject(); - + private router = inject(Router); get filtersList() { return this.filters ? Object.values(this.filters) : []; } @@ -85,6 +89,8 @@ export class VaultFilterComponent implements OnInit, OnDestroy { protected policyService: PolicyService, protected i18nService: I18nService, protected platformUtilsService: PlatformUtilsService, + protected billingApiService: BillingApiServiceAbstraction, + protected dialogService: DialogService, ) {} async ngOnInit(): Promise { @@ -111,6 +117,13 @@ export class VaultFilterComponent implements OnInit, OnDestroy { null, this.i18nService.t("disabledOrganizationFilterError"), ); + const metadata = await this.billingApiService.getOrganizationBillingMetadata(orgNode.node.id); + if (metadata.isSubscriptionUnpaid) { + const confirmed = await this.promptForPaymentNavigation(orgNode.node); + if (confirmed) { + await this.navigateToPaymentMethod(orgNode.node.id); + } + } return; } const filter = this.activeFilter; @@ -123,6 +136,32 @@ export class VaultFilterComponent implements OnInit, OnDestroy { await this.vaultFilterService.expandOrgFilter(); }; + private async promptForPaymentNavigation(org: Organization): Promise { + if (!org?.isOwner) { + await this.dialogService.openSimpleDialog({ + title: this.i18nService.t("suspendedOrganizationTitle", org?.name), + content: { key: "suspendedUserOrgMessage" }, + type: "danger", + acceptButtonText: this.i18nService.t("close"), + cancelButtonText: null, + }); + return false; + } + return await this.dialogService.openSimpleDialog({ + title: this.i18nService.t("suspendedOrganizationTitle", org?.name), + content: { key: "suspendedOwnerOrgMessage" }, + type: "danger", + acceptButtonText: this.i18nService.t("continue"), + cancelButtonText: this.i18nService.t("close"), + }); + } + + private async navigateToPaymentMethod(orgId: string) { + await this.router.navigate(["organizations", `${orgId}`, "billing", "payment-method"], { + state: { launchPaymentModalAutomatically: true }, + }); + } + applyTypeFilter = async (filterNode: TreeNode): Promise => { const filter = this.activeFilter; filter.resetFilter(); @@ -216,6 +255,12 @@ export class VaultFilterComponent implements OnInit, OnDestroy { type: CipherType.SecureNote, icon: "bwi-sticky-note", }, + { + id: "sshKey", + name: this.i18nService.t("typeSshKey"), + type: CipherType.SshKey, + icon: "bwi-key", + }, ]; const typeFilterSection: VaultFilterSection = { diff --git a/apps/web/src/app/vault/individual-vault/vault.component.html b/apps/web/src/app/vault/individual-vault/vault.component.html index b2c4fda57d0..679d2ce6f7e 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.html +++ b/apps/web/src/app/vault/individual-vault/vault.component.html @@ -1,4 +1,4 @@ - + ; private activeUserId: UserId; + protected organizationsPaymentStatus: FreeTrial[] = []; private searchText$ = new Subject(); private refresh$ = new BehaviorSubject(null); private destroy$ = new Subject(); private extensionRefreshEnabled: boolean; private vaultItemDialogRef?: DialogRef | undefined; + private readonly unpaidSubscriptionDialog$ = this.organizationService.organizations$.pipe( + filter((organizations) => organizations.length === 1), + switchMap(([organization]) => + from(this.billingApiService.getOrganizationBillingMetadata(organization.id)).pipe( + switchMap((organizationMetaData) => + from( + this.trialFlowService.handleUnpaidSubscriptionDialog( + organization, + organizationMetaData, + ), + ), + ), + ), + ), + ); constructor( private syncService: SyncService, @@ -211,6 +232,9 @@ export class VaultComponent implements OnInit, OnDestroy { private toastService: ToastService, private accountService: AccountService, private cipherFormConfigService: DefaultCipherFormConfigService, + private organizationApiService: OrganizationApiServiceAbstraction, + protected billingApiService: BillingApiServiceAbstraction, + private trialFlowService: TrialFlowService, ) {} async ngOnInit() { @@ -309,7 +333,6 @@ export class VaultComponent implements OnInit, OnDestroy { if (filter.collectionId === undefined || filter.collectionId === Unassigned) { return []; } - let collectionsToReturn = []; if (filter.organizationId !== undefined && filter.collectionId === All) { collectionsToReturn = collections @@ -362,7 +385,6 @@ export class VaultComponent implements OnInit, OnDestroy { filter(() => this.vaultItemDialogRef == undefined || !this.extensionRefreshEnabled), switchMap(async (params) => { const cipherId = getCipherIdFromParams(params); - if (cipherId) { if (await this.cipherService.get(cipherId)) { let action = params.action; @@ -393,6 +415,32 @@ export class VaultComponent implements OnInit, OnDestroy { ) .subscribe(); + this.unpaidSubscriptionDialog$.pipe(takeUntil(this.destroy$)).subscribe(); + + const organizationsPaymentStatus$ = this.organizationService.organizations$.pipe( + switchMap((allOrganizations) => { + return combineLatest( + allOrganizations + .filter((org) => org.isOwner) + .map((org) => + combineLatest([ + this.organizationApiService.getSubscription(org.id), + this.organizationApiService.getBilling(org.id), + ]).pipe( + map(([subscription, billing]) => { + return this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues( + org, + subscription, + billing?.paymentSource, + ); + }), + ), + ), + ); + }), + map((results) => results.filter((result) => result.shownBanner)), + ); + firstSetup$ .pipe( switchMap(() => this.refresh$), @@ -406,6 +454,7 @@ export class VaultComponent implements OnInit, OnDestroy { ciphers$, collections$, selectedCollection$, + organizationsPaymentStatus$, ]), ), takeUntil(this.destroy$), @@ -419,6 +468,7 @@ export class VaultComponent implements OnInit, OnDestroy { ciphers, collections, selectedCollection, + organizationsPaymentStatus, ]) => { this.filter = filter; this.canAccessPremium = canAccessPremium; @@ -434,7 +484,7 @@ export class VaultComponent implements OnInit, OnDestroy { this.showBulkMove = filter.type !== "trash"; this.isEmpty = collections?.length === 0 && ciphers?.length === 0; - + this.organizationsPaymentStatus = organizationsPaymentStatus; this.performingInitialLoad = false; this.refreshing = false; }, diff --git a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts index 02d280f5ff9..05c40fe2e79 100644 --- a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts +++ b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts @@ -1,14 +1,13 @@ import { TestBed } from "@angular/core/testing"; import { BehaviorSubject } from "rxjs"; -import { CollectionAdminService } from "@bitwarden/admin-console/common"; +import { CollectionAdminService, CollectionAdminView } from "@bitwarden/admin-console/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { CipherId } from "@bitwarden/common/types/guid"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { RoutedVaultFilterService } from "../../individual-vault/vault-filter/services/routed-vault-filter.service"; @@ -35,27 +34,41 @@ describe("AdminConsoleCipherFormConfigService", () => { status: OrganizationUserStatusType.Confirmed, }; const policyAppliesToActiveUser$ = new BehaviorSubject(true); + const collection = { + id: "12345-5555", + organizationId: "234534-34334", + name: "Test Collection 1", + assigned: false, + readOnly: true, + } as CollectionAdminView; + const collection2 = { + id: "12345-6666", + organizationId: "22222-2222", + name: "Test Collection 2", + assigned: true, + readOnly: false, + } as CollectionAdminView; + const organization$ = new BehaviorSubject(testOrg as Organization); const organizations$ = new BehaviorSubject([testOrg, testOrg2] as Organization[]); const getCipherAdmin = jest.fn().mockResolvedValue(null); - const getCipher = jest.fn().mockResolvedValue(null); beforeEach(async () => { getCipherAdmin.mockClear(); - getCipher.mockClear(); - getCipher.mockResolvedValue({ id: cipherId, name: "Test Cipher - (non-admin)" }); getCipherAdmin.mockResolvedValue({ id: cipherId, name: "Test Cipher - (admin)" }); await TestBed.configureTestingModule({ providers: [ AdminConsoleCipherFormConfigService, + { provide: OrganizationService, useValue: { get$: () => organization$, organizations$ } }, + { + provide: CollectionAdminService, + useValue: { getAll: () => Promise.resolve([collection, collection2]) }, + }, { provide: PolicyService, useValue: { policyAppliesToActiveUser$: () => policyAppliesToActiveUser$ }, }, - { provide: OrganizationService, useValue: { get$: () => organization$, organizations$ } }, - { provide: CipherService, useValue: { get: getCipher } }, - { provide: CollectionAdminService, useValue: { getAll: () => Promise.resolve([]) } }, { provide: RoutedVaultFilterService, useValue: { filter$: new BehaviorSubject({ organizationId: testOrg.id }) }, @@ -86,6 +99,12 @@ describe("AdminConsoleCipherFormConfigService", () => { expect(mode).toBe("edit"); }); + it("returns all collections", async () => { + const { collections } = await adminConsoleConfigService.buildConfig("edit", cipherId); + + expect(collections).toEqual([collection, collection2]); + }); + it("sets admin flag based on `canEditAllCiphers`", async () => { // Disable edit all ciphers on org testOrg.canEditAllCiphers = false; @@ -153,33 +172,14 @@ describe("AdminConsoleCipherFormConfigService", () => { expect(result.organizations).toEqual([testOrg, testOrg2]); }); - describe("getCipher", () => { - it("retrieves the cipher from the cipher service", async () => { - testOrg.canEditAllCiphers = false; + it("retrieves the cipher from the admin service", async () => { + getCipherAdmin.mockResolvedValue({ id: cipherId, name: "Test Cipher - (admin)" }); - adminConsoleConfigService = TestBed.inject(AdminConsoleCipherFormConfigService); + adminConsoleConfigService = TestBed.inject(AdminConsoleCipherFormConfigService); - const result = await adminConsoleConfigService.buildConfig("clone", cipherId); + await adminConsoleConfigService.buildConfig("add", cipherId); - expect(getCipher).toHaveBeenCalledWith(cipherId); - expect(result.originalCipher.name).toBe("Test Cipher - (non-admin)"); - - // Admin service not needed when cipher service can return the cipher - expect(getCipherAdmin).not.toHaveBeenCalled(); - }); - - it("retrieves the cipher from the admin service", async () => { - getCipher.mockResolvedValueOnce(null); - getCipherAdmin.mockResolvedValue({ id: cipherId, name: "Test Cipher - (admin)" }); - - adminConsoleConfigService = TestBed.inject(AdminConsoleCipherFormConfigService); - - await adminConsoleConfigService.buildConfig("add", cipherId); - - expect(getCipherAdmin).toHaveBeenCalledWith(cipherId); - - expect(getCipher).toHaveBeenCalledWith(cipherId); - }); + expect(getCipherAdmin).toHaveBeenCalledWith(cipherId); }); }); }); diff --git a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts index 328ab4475dc..457b4e83d03 100644 --- a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts +++ b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts @@ -6,9 +6,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType, OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { CipherId } from "@bitwarden/common/types/guid"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; @@ -25,7 +23,6 @@ import { RoutedVaultFilterService } from "../../individual-vault/vault-filter/se export class AdminConsoleCipherFormConfigService implements CipherFormConfigService { private policyService: PolicyService = inject(PolicyService); private organizationService: OrganizationService = inject(OrganizationService); - private cipherService: CipherService = inject(CipherService); private routedVaultFilterService: RoutedVaultFilterService = inject(RoutedVaultFilterService); private collectionAdminService: CollectionAdminService = inject(CollectionAdminService); private apiService: ApiService = inject(ApiService); @@ -51,20 +48,8 @@ export class AdminConsoleCipherFormConfigService implements CipherFormConfigServ map(([orgs, orgId]) => orgs.find((o) => o.id === orgId)), ); - private editableCollections$ = this.organization$.pipe( - switchMap(async (org) => { - if (!org) { - return []; - } - - const collections = await this.collectionAdminService.getAll(org.id); - // Users that can edit all ciphers can implicitly add to / edit within any collection - if (org.canEditAllCiphers) { - return collections; - } - // The user is only allowed to add/edit items to assigned collections that are not readonly - return collections.filter((c) => c.assigned && !c.readOnly); - }), + private allCollections$ = this.organization$.pipe( + switchMap(async (org) => await this.collectionAdminService.getAll(org.id)), ); async buildConfig( @@ -72,21 +57,17 @@ export class AdminConsoleCipherFormConfigService implements CipherFormConfigServ cipherId?: CipherId, cipherType?: CipherType, ): Promise { + const cipher = await this.getCipher(cipherId); const [organization, allowPersonalOwnership, allOrganizations, allCollections] = await firstValueFrom( combineLatest([ this.organization$, this.allowPersonalOwnership$, this.allOrganizations$, - this.editableCollections$, + this.allCollections$, ]), ); - const cipher = await this.getCipher(organization, cipherId); - - const collections = allCollections.filter( - (c) => c.organizationId === organization.id && c.assigned && !c.readOnly, - ); // When cloning from within the Admin Console, all organizations should be available. // Otherwise only the one in context should be const organizations = mode === "clone" ? allOrganizations : [organization]; @@ -100,7 +81,7 @@ export class AdminConsoleCipherFormConfigService implements CipherFormConfigServ admin: organization.canEditAllCiphers ?? false, allowPersonalOwnership: allowPersonalOwnershipOnlyForClone, originalCipher: cipher, - collections, + collections: allCollections, organizations, folders: [], // folders not applicable in the admin console hideIndividualVaultFields: true, @@ -108,19 +89,11 @@ export class AdminConsoleCipherFormConfigService implements CipherFormConfigServ }; } - private async getCipher(organization: Organization, id?: CipherId): Promise { + private async getCipher(id?: CipherId): Promise { if (id == null) { return Promise.resolve(null); } - // Check to see if the user has direct access to the cipher - const cipherFromCipherService = await this.cipherService.get(id); - - // If the organization doesn't allow admin/owners to edit all ciphers return the cipher - if (!organization.canEditAllCiphers && cipherFromCipherService != null) { - return cipherFromCipherService; - } - // Retrieve the cipher through the means of an admin const cipherResponse = await this.apiService.getCipherAdmin(id); cipherResponse.edit = true; diff --git a/apps/web/src/app/vault/org-vault/vault-filter/vault-filter.component.ts b/apps/web/src/app/vault/org-vault/vault-filter/vault-filter.component.ts index 8a3f25ab2c7..211d2346230 100644 --- a/apps/web/src/app/vault/org-vault/vault-filter/vault-filter.component.ts +++ b/apps/web/src/app/vault/org-vault/vault-filter/vault-filter.component.ts @@ -3,9 +3,11 @@ import { firstValueFrom, Subject } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { DialogService } from "@bitwarden/components"; import { VaultFilterComponent as BaseVaultFilterComponent } from "../../individual-vault/vault-filter/components/vault-filter.component"; //../../vault/vault-filter/components/vault-filter.component"; import { VaultFilterService } from "../../individual-vault/vault-filter/services/abstractions/vault-filter.service"; @@ -38,8 +40,17 @@ export class VaultFilterComponent protected policyService: PolicyService, protected i18nService: I18nService, protected platformUtilsService: PlatformUtilsService, + protected billingApiService: BillingApiServiceAbstraction, + protected dialogService: DialogService, ) { - super(vaultFilterService, policyService, i18nService, platformUtilsService); + super( + vaultFilterService, + policyService, + i18nService, + platformUtilsService, + billingApiService, + dialogService, + ); } async ngOnInit() { diff --git a/apps/web/src/app/vault/org-vault/vault.component.html b/apps/web/src/app/vault/org-vault/vault.component.html index 0bcdc52eaeb..9e9264e77cd 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.html +++ b/apps/web/src/app/vault/org-vault/vault.component.html @@ -1,3 +1,25 @@ + + + {{ freeTrial.message }} + + {{ "routeToPaymentMethodTrigger" | i18n }} + + + + ; + protected freeTrial$: Observable; /** * A list of collections that the user can assign items to and edit those items within. * @protected @@ -183,6 +197,21 @@ export class VaultComponent implements OnInit, OnDestroy { protected addAccessStatus$ = new BehaviorSubject(0); private extensionRefreshEnabled: boolean; private vaultItemDialogRef?: DialogRef | undefined; + private readonly unpaidSubscriptionDialog$ = this.organizationService.organizations$.pipe( + filter((organizations) => organizations.length === 1), + switchMap(([organization]) => + from(this.billingApiService.getOrganizationBillingMetadata(organization.id)).pipe( + switchMap((organizationMetaData) => + from( + this.trialFlowService.handleUnpaidSubscriptionDialog( + organization, + organizationMetaData, + ), + ), + ), + ), + ), + ); constructor( private route: ActivatedRoute, @@ -214,6 +243,9 @@ export class VaultComponent implements OnInit, OnDestroy { private toastService: ToastService, private configService: ConfigService, private cipherFormConfigService: CipherFormConfigService, + private organizationApiService: OrganizationApiServiceAbstraction, + private trialFlowService: TrialFlowService, + protected billingApiService: BillingApiServiceAbstraction, ) {} async ngOnInit() { @@ -546,6 +578,26 @@ export class VaultComponent implements OnInit, OnDestroy { ) .subscribe(); + this.unpaidSubscriptionDialog$.pipe(takeUntil(this.destroy$)).subscribe(); + + this.freeTrial$ = organization$.pipe( + filter((org) => org.isOwner), + switchMap((org) => + combineLatest([ + of(org), + this.organizationApiService.getSubscription(org.id), + this.organizationApiService.getBilling(org.id), + ]), + ), + map(([org, sub, billing]) => { + return this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues( + org, + sub, + billing?.paymentSource, + ); + }), + ); + firstSetup$ .pipe( switchMap(() => this.refresh$), @@ -596,6 +648,13 @@ export class VaultComponent implements OnInit, OnDestroy { ); } + async navigateToPaymentMethod() { + await this.router.navigate( + ["organizations", `${this.organization?.id}`, "billing", "payment-method"], + { state: { launchPaymentModalAutomatically: true } }, + ); + } + addAccessToggle(e: AddAccessStatusType) { this.addAccessStatus$.next(e); } diff --git a/apps/web/src/locales/af/messages.json b/apps/web/src/locales/af/messages.json index 3310b38ba6f..7837baaed5b 100644 --- a/apps/web/src/locales/af/messages.json +++ b/apps/web/src/locales/af/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/ar/messages.json b/apps/web/src/locales/ar/messages.json index 5698a2fb1b5..d3230826059 100644 --- a/apps/web/src/locales/ar/messages.json +++ b/apps/web/src/locales/ar/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/az/messages.json b/apps/web/src/locales/az/messages.json index 162954882f1..10f948b7a5b 100644 --- a/apps/web/src/locales/az/messages.json +++ b/apps/web/src/locales/az/messages.json @@ -5,14 +5,14 @@ "criticalApplications": { "message": "Kritik tətbiqlər" }, - "accessIntelligence": { - "message": "Müraciət Kəşfiyyatı" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Parol riski" }, "discoverAtRiskPasswords": { - "message": "Riskli parolları kəşf edin və bu parolları dəyişdirməsi üçün istifadəçiləri məlumatlandırın." + "message": "Discover at-risk passwords and notify users to change those passwords." }, "dataLastUpdated": { "message": "Datanın son güncəlləmə tarixi: $DATE$", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "1 dəvət haqqınız var." - }, "userUsingTwoStep": { "message": "Bu istifadəçinin hesabını qorumaq üçün iki addımlı giriş istifadə edilir." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Uyumlu olmayan üzvlər rədd ediləcək. Digər bütün təşkilatları tərk etdikdən sonra üzvlər, administratorlar tərəfindən bərpa edilə bilər." + }, + "deleteOrganizationUser": { + "message": "$NAME$ - sil", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "Bir hesab silindikdə, Bitwarden hesabı və onun fərdi seyf dataları həmişəlik silinir. Təşkilatdakı kolleksiya dataları qalır. Bunları yenidən fəallaşdırmaq üçün bir hesab yaradılmalı və yenidən təşkilata qoşulması lazımdır.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "$NAME$ silindi", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "İstifadəçi təşkilatdan çıxarıldı və əlaqələndirilmiş bütün istifadəçi dataları silindi." } } diff --git a/apps/web/src/locales/be/messages.json b/apps/web/src/locales/be/messages.json index ee0537a86c1..6268fe9c1db 100644 --- a/apps/web/src/locales/be/messages.json +++ b/apps/web/src/locales/be/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Гэты карыстальнік выкарыстоўвае двухэтапны ўваход для абароны свайго ўліковага запісу." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/bg/messages.json b/apps/web/src/locales/bg/messages.json index 3e48ade518c..fd236374cb9 100644 --- a/apps/web/src/locales/bg/messages.json +++ b/apps/web/src/locales/bg/messages.json @@ -5,14 +5,14 @@ "criticalApplications": { "message": "Важни приложения" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Рискова парола" }, "discoverAtRiskPasswords": { - "message": "Откриване на пароли в риск и известяване на потребителите да ги сменят." + "message": "Discover at-risk passwords and notify users to change those passwords." }, "dataLastUpdated": { "message": "Последно обновяване на данните: $DATE$", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "Имате 1 оставаща покана." - }, "userUsingTwoStep": { "message": "Този потребител използва двустепенна защита за достъп." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "На членовете, които не отговарят на това условие, ще бъдат отнети правомощията. Администраторите могат да възстановяват правомощията на членовете, след като те напуснат всички останали организации." + }, + "deleteOrganizationUser": { + "message": "Изтриване на $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "Когато даден член бъде изтрит, неговата регистрация в Битуорден, както и данните от трезора му, ще бъдат изтрити завинаги. Данните за колекции ще останат в организацията. Ако искате да го върнете, той трябва да си създаде нова регистрация и да бъде включен отново.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Изтрито: $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "Потребителят беше премахнат от организацията и всичките му данни бяха изтрити." } } diff --git a/apps/web/src/locales/bn/messages.json b/apps/web/src/locales/bn/messages.json index 6eff6b5deb8..934857a7d0f 100644 --- a/apps/web/src/locales/bn/messages.json +++ b/apps/web/src/locales/bn/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/bs/messages.json b/apps/web/src/locales/bs/messages.json index b5785942675..50d10c17db5 100644 --- a/apps/web/src/locales/bs/messages.json +++ b/apps/web/src/locales/bs/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/ca/messages.json b/apps/web/src/locales/ca/messages.json index ed831a39f8b..0974ed9b1a1 100644 --- a/apps/web/src/locales/ca/messages.json +++ b/apps/web/src/locales/ca/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Aquest usuari fa servir l'inici de sessió en dues passes per protegir el seu compte." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/cs/messages.json b/apps/web/src/locales/cs/messages.json index 7efd6d85959..c7a9b74c5db 100644 --- a/apps/web/src/locales/cs/messages.json +++ b/apps/web/src/locales/cs/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Kritické aplikace" }, - "accessIntelligence": { - "message": "Přístup k inteligenci" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Rizikové heslo" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "Zbývá Vám 1 pozvánka." - }, "userUsingTwoStep": { "message": "Tento uživatel používá pro ochranu svého účtu dvoufázové přihlášení." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Nevyhovujícím členům bude členství zrušeno. Správci mohou obnovit členy, jakmile opustí všechny ostatní organizace." + }, + "deleteOrganizationUser": { + "message": "Smazat $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "Pokud je člen smazán, jeho účet Bitwarden a individuální údaje z trezoru budou trvale smazány. Data kolekce zůstanou v organizaci. Pro jejich obnovení si musí vytvořit účet a být znovu zařazen do systému.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "$NAME$ - smazán", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "Uživatel byl odebrán z organizace a všechna přidružená uživatelská data byla smazána." } } diff --git a/apps/web/src/locales/cy/messages.json b/apps/web/src/locales/cy/messages.json index d8c96f419ab..4ca57a56bf2 100644 --- a/apps/web/src/locales/cy/messages.json +++ b/apps/web/src/locales/cy/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/da/messages.json b/apps/web/src/locales/da/messages.json index 4791cc3784a..25e85fef58b 100644 --- a/apps/web/src/locales/da/messages.json +++ b/apps/web/src/locales/da/messages.json @@ -5,14 +5,14 @@ "criticalApplications": { "message": "Kritiske applikationer" }, - "accessIntelligence": { - "message": "Adgangsintelligens" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Adgangskoderisiko" }, "discoverAtRiskPasswords": { - "message": "Opdag risikable adgangskoder og underret brugerne om at ændre disse." + "message": "Opdag udsatte adgangskoder og underret brugerne om at ændre disse." }, "dataLastUpdated": { "message": "Data senest opdateret: $DATE$", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "Der er 1 invitation tilbage." - }, "userUsingTwoStep": { "message": "Denne bruger benytter totrins-login for at beskytte kontoen." }, @@ -4689,7 +4686,7 @@ "message": "Begræns medlemmer i at tilmelde sig andre organisationer." }, "singleOrgPolicyDesc": { - "message": "Restrict members from joining other organizations. This policy is required for organizations that have enabled domain verification." + "message": "Begræns medlemmers deltagelse i andre organisationer. Denne politik kræves for organisationer med aktiveret domænebekræftelse." }, "singleOrgBlockCreateMessage": { "message": "Den nuværende organisationspolitik tillader dig ikke at deltage i mere end én organisation. Kontakt organisationens admins eller benyt en anden Bitwarden-konto under tilmelding." @@ -4698,7 +4695,7 @@ "message": "Organisationsmedlemmer, undtagen ejere eller admins, som allerede er medlem af en anden organisation, fjernes fra organisationen." }, "singleOrgPolicyMemberWarning": { - "message": "Non-compliant members will be placed in revoked status until they leave all other organizations. Administrators are exempt and can restore members once compliance is met." + "message": "Ikke-overholdende medlemmer placeres i privilegie-ophævet status, indtil de forlader alle øvrige organisationer. Administratorer er fritaget og kan gendanne medlemmer, når overholdelse er opfyldt." }, "requireSso": { "message": "Kræv single sign-on godkendelse" @@ -9553,9 +9550,35 @@ "message": "Selv-hosting" }, "verified-domain-single-org-warning": { - "message": "Verifying a domain will turn on the single organization policy." + "message": "Bekræftelse af et domæne vil slå den enkelte organisationspolitik til." }, "single-org-revoked-user-warning": { - "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + "message": "Ikke-overholdende medlemmers privilegier ophæves. Administratorer kan gendanne medlemmer, når overholdelse er opfyldt." + }, + "deleteOrganizationUser": { + "message": "Slet $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "Når medlemmer slettes, vil deres Bitwarden-konto og individuelle boksdata blive slettet permanent. Indsamlingsdata vil forblive i organisationen. For at genindsætte dem, skal de oprette en konto og onboarderes igen.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Slet $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "Brugeren er fjernet fra organisationen og alle tilknyttede brugerdata er slettet." } } diff --git a/apps/web/src/locales/de/messages.json b/apps/web/src/locales/de/messages.json index 0667b0d7fbd..01a34c0afb7 100644 --- a/apps/web/src/locales/de/messages.json +++ b/apps/web/src/locales/de/messages.json @@ -3,19 +3,19 @@ "message": "Alle Anwendungen" }, "criticalApplications": { - "message": "Critical applications" + "message": "Kritische Anwendungen" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { - "message": "Password Risk" + "message": "Passwort-Risiko" }, "discoverAtRiskPasswords": { "message": "Discover at-risk passwords and notify users to change those passwords." }, "dataLastUpdated": { - "message": "Data last updated: $DATE$", + "message": "Daten zuletzt aktualisiert: $DATE$", "placeholders": { "date": { "content": "$1", @@ -36,10 +36,10 @@ } }, "createNewLoginItem": { - "message": "Create new login item" + "message": "Neuen Zugangsdaten-Eintrag erstellen" }, "criticalApplicationsWithCount": { - "message": "Critical applications ($COUNT$)", + "message": "Kritische Anwendungen ($COUNT$)", "placeholders": { "count": { "content": "$1", @@ -57,7 +57,7 @@ } }, "noAppsInOrgTitle": { - "message": "No applications found in $ORG NAME$", + "message": "Keine Anwendungen in $ORG NAME$ gefunden", "placeholders": { "org name": { "content": "$1", @@ -66,22 +66,22 @@ } }, "noAppsInOrgDescription": { - "message": "As users save logins, applications appear here, showing any at-risk passwords. Mark critical apps and notify users to update passwords." + "message": "Während Benutzer Zugangsdaten speichern, werden hier Anwendungen angezeigt, die alle gefährdeten Passwörter anzeigen. Markiere kritische Anwendungen und benachrichtige Benutzer, um Passwörter zu ändern." }, "noCriticalAppsTitle": { - "message": "You haven't marked any applications as a Critical" + "message": "Du hast keine Anwendungen als kritisch markiert" }, "noCriticalAppsDescription": { - "message": "Select your most critical applications to discover at-risk passwords, and notify users to change those passwords." + "message": "Wähle deine wichtigsten Anwendungen aus, um gefährdete Passwörter zu ermitteln, und benachrichtige die Benutzer, damit sie diese Passwörter ändern." }, "markCriticalApps": { - "message": "Mark critical apps" + "message": "Kritische Anwendungen markieren" }, "markAppAsCritical": { - "message": "Mark app as critical" + "message": "Anwendung als kritisch markieren" }, "appsMarkedAsCritical": { - "message": "Apps marked as critical" + "message": "Als kritisch markierte Anwendungen" }, "application": { "message": "Anwendung" @@ -90,13 +90,13 @@ "message": "Risikoreiche Passwörter" }, "requestPasswordChange": { - "message": "Request password change" + "message": "Passwortänderung anfordern" }, "totalPasswords": { "message": "Passwörter insgesamt" }, "searchApps": { - "message": "Search applications" + "message": "Anwendungen suchen" }, "atRiskMembers": { "message": "Risikoreiche Mitglieder" @@ -1738,7 +1738,7 @@ "message": "Vorsicht, diese Aktionen sind nicht umkehrbar!" }, "dangerZoneDescSingular": { - "message": "Careful, this action is not reversible!" + "message": "Vorsicht, diese Aktion ist nicht mehr rückgängig zu machen!" }, "deauthorizeSessions": { "message": "Sitzungen abmelden" @@ -1753,7 +1753,7 @@ "message": "Alle Sitzungen wurden abgemeldet" }, "accountIsManagedMessage": { - "message": "This account is managed by $ORGANIZATIONNAME$", + "message": "Dieses Konto wird von $ORGANIZATIONNAME$ verwaltet", "placeholders": { "organizationName": { "content": "$1", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Dieser Benutzer hat sein Konto mit einer Zwei-Faktor-Authentifizierung geschützt." }, @@ -4689,7 +4686,7 @@ "message": "Benutzern verbieten, anderen Organisationen beizutreten." }, "singleOrgPolicyDesc": { - "message": "Restrict members from joining other organizations. This policy is required for organizations that have enabled domain verification." + "message": "Verbiete Mitgliedern den Beitritt zu anderen Organisationen. Diese Richtlinie ist für Organisationen erforderlich, die die Domain-Verifizierung aktiviert haben." }, "singleOrgBlockCreateMessage": { "message": "Deine aktuelle Organisation hat eine Richtlinie, die es dir nicht erlaubt, mehr als einer Organisation beizutreten. Bitte kontaktiere die Administratoren deiner Organisation oder melden dich mit einem anderen Bitwarden-Konto an." @@ -4698,7 +4695,7 @@ "message": "Organisationsmitglieder, die nicht Eigentümer oder Administratoren sind und bereits Mitglied einer anderen Organisation sind, werden aus deiner Organisation entfernt." }, "singleOrgPolicyMemberWarning": { - "message": "Non-compliant members will be placed in revoked status until they leave all other organizations. Administrators are exempt and can restore members once compliance is met." + "message": "Nicht-konforme Mitglieder werden in den Widerrufen-Status versetzt, bis sie alle anderen Organisationen verlassen. Administratoren sind ausgenommen und können Mitglieder wieder aufnehmen, sobald die Anforderungen erfüllt sind." }, "requireSso": { "message": "Single Sign-on-Authentifizierung erfordern" @@ -5274,7 +5271,7 @@ "message": "Öffne deine Organisations-" }, "usingTheMenuSelect": { - "message": "Using the menu, select" + "message": "Wählen Sie das Menü aus" }, "toGrantAccessToSelectedMembers": { "message": "um Zugriff auf ausgewählte Mitglieder zu gewähren." @@ -6417,7 +6414,7 @@ "message": "E-Mail generieren" }, "generatorBoundariesHint": { - "message": "Value must be between $MIN$ and $MAX$", + "message": "Wert muss zwischen $MIN$ und $MAX$ liegen", "description": "Explains spin box minimum and maximum values to the user", "placeholders": { "min": { @@ -9072,7 +9069,7 @@ "message": "Geheimnis anzeigen" }, "noClients": { - "message": "There are no clients to list" + "message": "Keine Ereignisse vorhanden." }, "providerBillingEmailHint": { "message": "Diese E-Mail-Adresse wird alle Rechnungen erhalten, die diesen Anbieter betreffen", @@ -9187,7 +9184,7 @@ "description": "The status of an invoice." }, "clientDetails": { - "message": "Client details" + "message": "Kundendetails" }, "downloadCSV": { "message": "CSV-Datei herunterladen" @@ -9333,7 +9330,7 @@ "message": "Text-Sends" }, "includesXMembers": { - "message": "for $COUNT$ member", + "message": "für $COUNT$ Mitglied", "placeholders": { "count": { "content": "$1", @@ -9543,7 +9540,7 @@ "message": "Bist du sicher, dass du diesen Anhang dauerhaft löschen möchtest?" }, "manageSubscriptionFromThe": { - "message": "Manage subscription from the", + "message": "Abonnement verwalten von der", "description": "This represents the beginning of a sentence. The full sentence will be 'Manage subscription from the Provider Portal', but 'Provider Portal' will be a link and thus cannot be included in the translation file." }, "toHostBitwardenOnYourOwnServer": { @@ -9553,9 +9550,35 @@ "message": "Selbst gehostet" }, "verified-domain-single-org-warning": { - "message": "Verifying a domain will turn on the single organization policy." + "message": "Die Domain-Verifizierung aktiviert die Richtlinie für einzelne Organisationen." }, "single-org-revoked-user-warning": { - "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + "message": "Nicht konforme Mitglieder werden gesperrt. Administratoren können Mitglieder wieder aufnehmen, sobald sie alle anderen Organisationen verlassen." + }, + "deleteOrganizationUser": { + "message": "$NAME$ gelöscht", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "Wenn ein Mitglied gelöscht wird, werden sein Bitwarden-Konto und persönlichen Tresor-Daten dauerhaft gelöscht. Sammlungs-Daten bleiben in der Organisation. Um sie wiederherzustellen, müssen diese ein Konto erstellen und den Onboarding-Prozess erneut durchlaufen.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "$NAME$ gelöscht", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "Der Benutzer wurde aus der Organisation entfernt und alle zugehörigen Benutzerdaten wurden gelöscht." } } diff --git a/apps/web/src/locales/el/messages.json b/apps/web/src/locales/el/messages.json index 30f1945600b..eeb9cc14acb 100644 --- a/apps/web/src/locales/el/messages.json +++ b/apps/web/src/locales/el/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Αυτός ο χρήστης χρησιμοποιεί τρόπο σύνδεσης δύο βημάτων για να προστατεύσει το λογαριασμό του." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 05332032e0a..00d2102c786 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -559,6 +559,9 @@ "typeSecureNote": { "message": "Secure note" }, + "typeSshKey": { + "message": "SSH key" + }, "typeLoginPlural": { "message": "Logins" }, @@ -3834,6 +3837,55 @@ "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, + "freeTrialEndPrompt": { + "message": "Your free trial ends in $COUNT$ days. To maintain your subscription,", + "placeholders": { + "count": { + "content": "$1", + "example": "You must set up 2FA on your user account before you can join this organization." + } + } + }, + "freeTrialEndPromptAboveTwoDays": { + "message": "$ORGANIZATION$, your free trial ends in $COUNT$ days. To maintain your subscription,", + "placeholders": { + "count": { + "content": "$2", + "example": "organization name" + }, + "organization": { + "content": "$1", + "example": "remaining days" + } + } + }, + "freeTrialEndPromptForOneDay": { + "message": "$ORGANIZATION$, your free trial ends tomorrow. To maintain your subscription,", + "placeholders": { + "organization": { + "content": "$1", + "example": "organization name" + } + } + }, + "freeTrialEndPromptForOneDayNoOrgName": { + "message": "Your free trial ends tomorrow. To maintain your subscription," + }, + "freeTrialEndPromptForLessThanADay": { + "message": "$ORGANIZATION$, your free trial ends today. To maintain your subscription,", + "placeholders": { + "organization": { + "content": "$1", + "example": "organization name" + } + } + }, + "freeTrialEndingSoonWithoutOrgName": { + "message": "Your free trial ends today. To maintain your subscription," + }, + "routeToPaymentMethodTrigger": { + "message": "add a payment method." + }, "joinOrganization": { "message": "Join organization" }, @@ -8441,7 +8493,7 @@ }, "addAPaymentMethod": { "message": "add a payment method", - "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'To maintain your subscription for $ORG$, add a payment method.'" + "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'To maintain your subscription for $ORG$, add a payment method'" }, "organizationInformation": { "message": "Organization information" @@ -9014,6 +9066,12 @@ "providerPlan": { "message": "Managed Service Provider" }, + "managedServiceProvider": { + "message": "Managed service provider" + }, + "multiOrganizationEnterprise": { + "message": "Multi-organization enterprise" + }, "orgSeats": { "message": "Organization Seats" }, @@ -9416,6 +9474,30 @@ "additionalStorageGbMessage": { "message": "GB additional storage" }, + "sshKeyAlgorithm": { + "message": "Key algorithm" + }, + "sshKeyFingerprint": { + "message": "Fingerprint" + }, + "sshKeyPrivateKey": { + "message": "Private key" + }, + "sshKeyPublicKey": { + "message": "Public key" + }, + "sshKeyAlgorithmED25519": { + "message": "ED25519" + }, + "sshKeyAlgorithmRSA2048": { + "message": "RSA 2048-Bit" + }, + "sshKeyAlgorithmRSA3072": { + "message": "RSA 3072-Bit" + }, + "sshKeyAlgorithmRSA4096": { + "message": "RSA 4096-Bit" + }, "premiumAccounts": { "message": "6 premium accounts" }, @@ -9580,5 +9662,38 @@ }, "organizationUserDeletedDesc": { "message": "The user was removed from the organization and all associated user data has been deleted." + }, + "deletedUserId": { + "message": "Deleted user $ID$ - an owner / admin deleted the user account", + "placeholders": { + "id": { + "content": "$1", + "example": "First 8 Character of a GUID" + } + } + }, + "userLeftOrganization": { + "message": "User $ID$ left organization", + "placeholders": { + "id": { + "content": "$1", + "example": "First 8 Character of a GUID" + } + } + }, + "suspendedOrganizationTitle": { + "message": "The $ORGANIZATION$ is suspended", + "placeholders": { + "organization": { + "content": "$1", + "example": "Acme c" + } + } + }, + "suspendedUserOrgMessage": { + "message": "Contact your organization owner for assistance." + }, + "suspendedOwnerOrgMessage": { + "message": "To regain access to your organization, add a payment method." } } diff --git a/apps/web/src/locales/en_GB/messages.json b/apps/web/src/locales/en_GB/messages.json index 431eeeeebcd..7fac11d847c 100644 --- a/apps/web/src/locales/en_GB/messages.json +++ b/apps/web/src/locales/en_GB/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organisations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organisation. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organisation and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/en_IN/messages.json b/apps/web/src/locales/en_IN/messages.json index 0e16ab3bd6e..96d0e5403bf 100644 --- a/apps/web/src/locales/en_IN/messages.json +++ b/apps/web/src/locales/en_IN/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organisations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organisation. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organisation and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/eo/messages.json b/apps/web/src/locales/eo/messages.json index 55bcef6cb15..ccc026e2120 100644 --- a/apps/web/src/locales/eo/messages.json +++ b/apps/web/src/locales/eo/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Ĉi tiu uzanto uzas du-paŝan ensaluton por protekti sian konton." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/es/messages.json b/apps/web/src/locales/es/messages.json index 20fe6ea933e..c053473ac9d 100644 --- a/apps/web/src/locales/es/messages.json +++ b/apps/web/src/locales/es/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Este usuario está usando autenticación de dos pasos para proteger su cuenta." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/et/messages.json b/apps/web/src/locales/et/messages.json index fff2bb8cd5d..6ba79ebe6c1 100644 --- a/apps/web/src/locales/et/messages.json +++ b/apps/web/src/locales/et/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Sellel kasutajal on kaheastmeline kinnitamine sisse lülitatud." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/eu/messages.json b/apps/web/src/locales/eu/messages.json index ac7ea7b93af..9f67ef96101 100644 --- a/apps/web/src/locales/eu/messages.json +++ b/apps/web/src/locales/eu/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Erabiltzaile hau bi urratseko saio hasiera erabiltzen ari da bere kontua babesteko." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/fa/messages.json b/apps/web/src/locales/fa/messages.json index f92ddcd9035..e4903ab83a3 100644 --- a/apps/web/src/locales/fa/messages.json +++ b/apps/web/src/locales/fa/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "این کاربر از ورود دو مرحله ای برای محافظت از حساب خود استفاده میکند." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/fi/messages.json b/apps/web/src/locales/fi/messages.json index 02238935f35..69d53ee762d 100644 --- a/apps/web/src/locales/fi/messages.json +++ b/apps/web/src/locales/fi/messages.json @@ -5,14 +5,14 @@ "criticalApplications": { "message": "Kriittiset sovellukset" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Salasanariski" }, "discoverAtRiskPasswords": { - "message": "Havaitse vaarantuneet salasanat ja ilmoita käyttäjille niiden vaihdosta." + "message": "Discover at-risk passwords and notify users to change those passwords." }, "dataLastUpdated": { "message": "Tiedot päivitetty viimeksi: $DATE$", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "Sinulla on yksi kutsu jäljellä." - }, "userUsingTwoStep": { "message": "Käyttäjä on suojannut tilinsä kaksivaiheisella kirjautumisella." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/fil/messages.json b/apps/web/src/locales/fil/messages.json index 119381538b4..7b880a85b52 100644 --- a/apps/web/src/locales/fil/messages.json +++ b/apps/web/src/locales/fil/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Gumagamit ang user na ito ng dalawang hakbang na pag login upang maprotektahan ang kanilang account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/fr/messages.json b/apps/web/src/locales/fr/messages.json index f1a1ffbf4a4..270c93b2c5f 100644 --- a/apps/web/src/locales/fr/messages.json +++ b/apps/web/src/locales/fr/messages.json @@ -5,14 +5,14 @@ "criticalApplications": { "message": "Applications critiques" }, - "accessIntelligence": { - "message": "Accéder à l'Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Risque du mot de passe" }, "discoverAtRiskPasswords": { - "message": "Découvrez les mots de passe à risque et avertissez les utilisateurs de modifier ces mots de passe." + "message": "Discover at-risk passwords and notify users to change those passwords." }, "dataLastUpdated": { "message": "Dernière mise à jour des données : $DATE$", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "Il vous reste 1 invitation." - }, "userUsingTwoStep": { "message": "Cet utilisateur utilise l'authentification à deux facteurs pour protéger son compte." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Les membres non conformes seront révoqués. Les administrateurs peuvent restaurer les membres une fois qu'ils quittent toutes les autres organisations." + }, + "deleteOrganizationUser": { + "message": "Supprimer $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "Lorsqu'un membre est supprimé, son compte Bitwarden et les données individuelles du coffre seront définitivement supprimées. Les données de Collection resteront dans l'organisation. Pour les rétablir, ils doivent créer un compte et être intégrés à nouveau.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Supprimer $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "L'utilisateur a été supprimé de l'organisation et toutes les données utilisateur associées ont été supprimées." } } diff --git a/apps/web/src/locales/gl/messages.json b/apps/web/src/locales/gl/messages.json index dda5e85f4cb..b5a1650478f 100644 --- a/apps/web/src/locales/gl/messages.json +++ b/apps/web/src/locales/gl/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/he/messages.json b/apps/web/src/locales/he/messages.json index 768e877cd2e..930db2948e4 100644 --- a/apps/web/src/locales/he/messages.json +++ b/apps/web/src/locales/he/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "משתמש זה הפעיל כניסה דו שלבית כדי להגן על חשבונו." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/hi/messages.json b/apps/web/src/locales/hi/messages.json index a20f1ec13bf..d815051acc1 100644 --- a/apps/web/src/locales/hi/messages.json +++ b/apps/web/src/locales/hi/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/hr/messages.json b/apps/web/src/locales/hr/messages.json index ef64895f899..d45357dc517 100644 --- a/apps/web/src/locales/hr/messages.json +++ b/apps/web/src/locales/hr/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Ovaj korisnik upotrebljava prijavu u dva koraka za zaštitu svog računa." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/hu/messages.json b/apps/web/src/locales/hu/messages.json index 0ee0f1c5370..95ecaecc6b4 100644 --- a/apps/web/src/locales/hu/messages.json +++ b/apps/web/src/locales/hu/messages.json @@ -5,14 +5,14 @@ "criticalApplications": { "message": "Kritikus alkalmazások" }, - "accessIntelligence": { - "message": "Elérés intelligencia" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Jelszó kockázat" }, "discoverAtRiskPasswords": { - "message": "Fedezzük fel a veszélyeztetett jelszavakat és értesítsük a felhasználókat, hogy módosítsák ezeket a jelszavakat." + "message": "Discover at-risk passwords and notify users to change those passwords." }, "dataLastUpdated": { "message": "Az adatok utolsó frissítése: $DATE$", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "1 meghívó maradt." - }, "userUsingTwoStep": { "message": "Ez a felhasználó kétlépcsős bejelentkezést használ fiókja védelmére." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "A nem megfelelő tagok visszavonásra kerülnek. Az adminisztrátorok visszaállíthatják a tagokat, miután elhagyják az összes többi szervezetet." + }, + "deleteOrganizationUser": { + "message": "$NAME$ törlése", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "Amikor egy tag törlésre kerül, a Bitwarden fiókjuk és az egyéni széf adataik véglegesen törlésre kerülnek. A gyűjtési adatok a szervezetben maradnak. A visszaállításukhoz egy fiókot kell létrehozni és újra be kell lépni.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "$NAME$ törlésre került.", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "A felhasználó eltávolításra került a szervezetből és az összes kapcsolódó felhasználói adat törlésre került." } } diff --git a/apps/web/src/locales/id/messages.json b/apps/web/src/locales/id/messages.json index f29e7780f73..0a9b35b4b12 100644 --- a/apps/web/src/locales/id/messages.json +++ b/apps/web/src/locales/id/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Pengguna ini menggunakan proses masuk dua langkah untuk melindungi akun mereka." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/it/messages.json b/apps/web/src/locales/it/messages.json index 906e3e41078..5f7a48bf3a8 100644 --- a/apps/web/src/locales/it/messages.json +++ b/apps/web/src/locales/it/messages.json @@ -5,14 +5,14 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Intelligence sugli accessi" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Rischio password" }, "discoverAtRiskPasswords": { - "message": "Scopri le password a rischio e richiedi agli utenti di cambiarle." + "message": "Discover at-risk passwords and notify users to change those passwords." }, "dataLastUpdated": { "message": "Ultimo aggiornamento: $DATE$", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Questo utente usa la verifica in due passaggi per proteggere il suo account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/ja/messages.json b/apps/web/src/locales/ja/messages.json index c9bd116eb69..979c22eb99d 100644 --- a/apps/web/src/locales/ja/messages.json +++ b/apps/web/src/locales/ja/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "インテリジェンスへのアクセス" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "このユーザーはアカウントを保護するため二段階認証を利用しています。" }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/ka/messages.json b/apps/web/src/locales/ka/messages.json index af3f8f4edd1..3f837d2745a 100644 --- a/apps/web/src/locales/ka/messages.json +++ b/apps/web/src/locales/ka/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/km/messages.json b/apps/web/src/locales/km/messages.json index 99e5f7bcf32..57cc9a404e1 100644 --- a/apps/web/src/locales/km/messages.json +++ b/apps/web/src/locales/km/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/kn/messages.json b/apps/web/src/locales/kn/messages.json index b3df003e79b..867ad7c2624 100644 --- a/apps/web/src/locales/kn/messages.json +++ b/apps/web/src/locales/kn/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "ಈ ಬಳಕೆದಾರರು ತಮ್ಮ ಖಾತೆಯನ್ನು ರಕ್ಷಿಸಲು ಎರಡು-ಹಂತದ ಲಾಗಿನ್ ಅನ್ನು ಬಳಸುತ್ತಿದ್ದಾರೆ." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/ko/messages.json b/apps/web/src/locales/ko/messages.json index 2be44401f9e..8b7474784a5 100644 --- a/apps/web/src/locales/ko/messages.json +++ b/apps/web/src/locales/ko/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "이 사용자는 계정을 보호하기 위해 2단계 로그인을 사용하고 있습니다." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/lv/messages.json b/apps/web/src/locales/lv/messages.json index 07f92f54970..0ee268beac9 100644 --- a/apps/web/src/locales/lv/messages.json +++ b/apps/web/src/locales/lv/messages.json @@ -5,14 +5,14 @@ "criticalApplications": { "message": "Kritiskas lietotnes" }, - "accessIntelligence": { - "message": "Piekļuves inteliģence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Paroļu risks" }, "discoverAtRiskPasswords": { - "message": "Atklāj riskam pakļautas paroles un apziņo lietotājus, lai tās nomaina!" + "message": "Discover at-risk passwords and notify users to change those passwords." }, "dataLastUpdated": { "message": "Dati pēdējoreiz atjaunināti: $DATE$", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "Tev ir atlicis 1 uzaicinājums." - }, "userUsingTwoStep": { "message": "Šis lietotājs izmanto divpakāpju pieteikšanos, lai aizsargātu savu kontu." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Pamatnostādnei neatbilstošie dalībnieki tiks atsaukti. Pārvaldītāji var atjaunot dalībniekus, tiklīdz viņi pametīs visas pārējās apvienības." + }, + "deleteOrganizationUser": { + "message": "Izdzēst $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "Kad dalībnieks tiek izdzēsts, viņa Bitwarden konts un personīgās glabātavas dati tiks neatgriezeniski izdzēsti. Krājumu dati paliks apvienībām. Lai atjaunotu dalībniekus, viņiem atkārtoti jāizveido konts un jāpievieno komandai.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Izdzēsts/a $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "Lietotājs tika noņemts no apvienības, un visa saistītā lietotāja informācija tika izdzēsta." } } diff --git a/apps/web/src/locales/ml/messages.json b/apps/web/src/locales/ml/messages.json index 0a2ec292d8a..7a71b4b07c9 100644 --- a/apps/web/src/locales/ml/messages.json +++ b/apps/web/src/locales/ml/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "ഈ ഉപയോക്താവ് അവരുടെ അക്കൗണ്ട് രണ്ട്-പ്രവേശനം ഉപയോഗിച്ച് സുരക്ഷിതമാക്കിയിരിക്കുന്നു." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/mr/messages.json b/apps/web/src/locales/mr/messages.json index 99e5f7bcf32..57cc9a404e1 100644 --- a/apps/web/src/locales/mr/messages.json +++ b/apps/web/src/locales/mr/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/my/messages.json b/apps/web/src/locales/my/messages.json index 99e5f7bcf32..57cc9a404e1 100644 --- a/apps/web/src/locales/my/messages.json +++ b/apps/web/src/locales/my/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/nb/messages.json b/apps/web/src/locales/nb/messages.json index b4baaa41e12..59e0334fde4 100644 --- a/apps/web/src/locales/nb/messages.json +++ b/apps/web/src/locales/nb/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Denne brukeren bruker 2-trinnsinnlogging til å beskytte kontoen sin." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/ne/messages.json b/apps/web/src/locales/ne/messages.json index 828b477d70b..5bcb5fb6c24 100644 --- a/apps/web/src/locales/ne/messages.json +++ b/apps/web/src/locales/ne/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/nl/messages.json b/apps/web/src/locales/nl/messages.json index 6ed2bf68a7b..7595736573c 100644 --- a/apps/web/src/locales/nl/messages.json +++ b/apps/web/src/locales/nl/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Belangrijke applicaties" }, - "accessIntelligence": { - "message": "Toegangsintelligentie" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Wachtwoordrisico" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "Je hebt 1 uitnodiging over." - }, "userUsingTwoStep": { "message": "Het account van deze gebruiker is beschermd met tweestapsaanmelding." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Niet-conforme leden worden ingetrokken. Beheerders kunnen leden herstellen zodra ze alle andere organisaties verlaten." + }, + "deleteOrganizationUser": { + "message": "$NAME$ verwijderen", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "Als je een lid verwijdert, verwijder je permanent hun Bitwarden-account en individuele kluisgegevens. Collectiegegevens blijven in de organisatie. Om het account te heractiveren, moet het lid een account aanmaken en opnieuw on-boarding doorlopen.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "$NAME$ verwijderd", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "De gebruiker is verwijderd uit de organisatie en alle bijbehorende gebruikersgegevens zijn verwijderd." } } diff --git a/apps/web/src/locales/nn/messages.json b/apps/web/src/locales/nn/messages.json index 1611c91135d..7bbd7e75bf5 100644 --- a/apps/web/src/locales/nn/messages.json +++ b/apps/web/src/locales/nn/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/or/messages.json b/apps/web/src/locales/or/messages.json index 99e5f7bcf32..57cc9a404e1 100644 --- a/apps/web/src/locales/or/messages.json +++ b/apps/web/src/locales/or/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/pl/messages.json b/apps/web/src/locales/pl/messages.json index 2ca3fd14060..13b870d4706 100644 --- a/apps/web/src/locales/pl/messages.json +++ b/apps/web/src/locales/pl/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Ten użytkownik korzysta z logowania dwustopniowego, aby chronić swoje konto." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/pt_BR/messages.json b/apps/web/src/locales/pt_BR/messages.json index 75b86c3720f..5984d3472e4 100644 --- a/apps/web/src/locales/pt_BR/messages.json +++ b/apps/web/src/locales/pt_BR/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Acessar a Inteligência" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Este usuário está usando o login em duas etapas para proteger a sua conta." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/pt_PT/messages.json b/apps/web/src/locales/pt_PT/messages.json index ddde15005b2..a335b157978 100644 --- a/apps/web/src/locales/pt_PT/messages.json +++ b/apps/web/src/locales/pt_PT/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Aplicações críticas" }, - "accessIntelligence": { - "message": "Acesso à informação" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Risco da palavra-passe" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "Ainda tem 1 convite." - }, "userUsingTwoStep": { "message": "Este utilizador está a utilizar a verificação de dois passos para proteger a sua conta." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Os membros não conformes serão revogados. Os administradores podem restaurar os membros quando estes saírem de todas as outras organizações." + }, + "deleteOrganizationUser": { + "message": "Eliminar $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "Quando um membro é eliminado, a sua conta Bitwarden e os dados individuais do cofre serão permanentemente eliminados. Os dados da coleção permanecerão na organização. Para os reintegrar, têm de criar uma conta e ser novamente integrados.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "$NAME$ eliminado", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "O utilizador foi removido da organização e todos os dados de utilizador associados foram eliminados." } } diff --git a/apps/web/src/locales/ro/messages.json b/apps/web/src/locales/ro/messages.json index d925bf1003f..8f57c764c5e 100644 --- a/apps/web/src/locales/ro/messages.json +++ b/apps/web/src/locales/ro/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "Acest utilizator folosește conectarea în două etape pentru a-și proteja contul." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/ru/messages.json b/apps/web/src/locales/ru/messages.json index ef6c6607778..21928393746 100644 --- a/apps/web/src/locales/ru/messages.json +++ b/apps/web/src/locales/ru/messages.json @@ -5,14 +5,14 @@ "criticalApplications": { "message": "Критичные приложения" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Риск пароля" }, "discoverAtRiskPasswords": { - "message": "Обнаружение паролей, подверженных риску, и уведомление пользователей о необходимости сменить эти пароли." + "message": "Discover at-risk passwords and notify users to change those passwords." }, "dataLastUpdated": { "message": "Последнее обновление: $DATE$", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "У вас осталось 1 приглашение." - }, "userUsingTwoStep": { "message": "Этот пользователь использует двухэтапную аутентификацию для защиты своего аккаунта." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Участники, не соблюдающие требования, будут аннулированы. Администраторы могут восстановить участников, как только они покинут все другие организации." + }, + "deleteOrganizationUser": { + "message": "Удалить $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "При удалении участника его аккаунт Bitwarden и личные данные хранилища будут удалены навсегда. Данные коллекций останутся в организации. Чтобы восстановить их, необходимо создать аккаунт и заново пройти процедуру регистрации.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Аккаунт $NAME$ удален", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "Пользователь был удален из организации, и все связанные с ним данные были удалены." } } diff --git a/apps/web/src/locales/si/messages.json b/apps/web/src/locales/si/messages.json index cd1f0c408d8..76be2326ec0 100644 --- a/apps/web/src/locales/si/messages.json +++ b/apps/web/src/locales/si/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/sk/messages.json b/apps/web/src/locales/sk/messages.json index 8d7de3775d8..e353e2e4605 100644 --- a/apps/web/src/locales/sk/messages.json +++ b/apps/web/src/locales/sk/messages.json @@ -5,14 +5,14 @@ "criticalApplications": { "message": "Kritické aplikácie" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Ohrozenie hesla" }, "discoverAtRiskPasswords": { - "message": "Odhaľte ohrozené hesla a upozornite používateľov, aby si ich zmenili." + "message": "Discover at-risk passwords and notify users to change those passwords." }, "dataLastUpdated": { "message": "Posledná aktualizácia dát: $DATE$", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "Ostáva vám 1 pozvánka." - }, "userUsingTwoStep": { "message": "Tento používateľ používa dvojstupňové overovanie aby si zabezpečil konto." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Členovia, ktorí nedodržiavajú pravidlo, budú odvolaní. Správcovia môžu obnoviť členov po ich odchode zo všetkých ostatných organizácií." + }, + "deleteOrganizationUser": { + "message": "Odstrániť $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "Keď je člen odstránený, jeho účet Bitwarden a individuálne údaje z trezora sa natrvalo odstránia. Údaje zo zbierky zostanú v organizácii. Ak ho chcete znovu pridať, musí si vytvoriť účet a byť znovu zaradený do systému.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Odstránený $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "Používateľ bol odstránený z organizácie a všetky súvisiace dáta boli vymazané." } } diff --git a/apps/web/src/locales/sl/messages.json b/apps/web/src/locales/sl/messages.json index d827f4cec4c..eafa7de2bfe 100644 --- a/apps/web/src/locales/sl/messages.json +++ b/apps/web/src/locales/sl/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/sr/messages.json b/apps/web/src/locales/sr/messages.json index 78f298b9406..9a568d445e2 100644 --- a/apps/web/src/locales/sr/messages.json +++ b/apps/web/src/locales/sr/messages.json @@ -5,14 +5,14 @@ "criticalApplications": { "message": "Критичне апликације" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Ризик од лозинке" }, "discoverAtRiskPasswords": { - "message": "Откријте ризичне лозинке и обавестите кориснике да промене те лозинке." + "message": "Discover at-risk passwords and notify users to change those passwords." }, "dataLastUpdated": { "message": "Подаци су последњи пут ажурирани: $DATE$", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "Имате још 1 позивницу." - }, "userUsingTwoStep": { "message": "Овај корисник користи пријаву у два корака за заштиту свог налога." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Чланови који не испуњавају услове биће опозвани. Администратори могу вратити чланове када напусте све друге организације." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/sr_CS/messages.json b/apps/web/src/locales/sr_CS/messages.json index fd22d2d8cf4..7c46cf4275f 100644 --- a/apps/web/src/locales/sr_CS/messages.json +++ b/apps/web/src/locales/sr_CS/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/sv/messages.json b/apps/web/src/locales/sv/messages.json index 8eacf15fb31..ecf9a0a5e4f 100644 --- a/apps/web/src/locales/sv/messages.json +++ b/apps/web/src/locales/sv/messages.json @@ -1,18 +1,18 @@ { "allApplications": { - "message": "All applications" + "message": "Alla applikationer" }, "criticalApplications": { "message": "Kritiska applikationer" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { - "message": "Password Risk" + "message": "Lösenordsrisk" }, "discoverAtRiskPasswords": { - "message": "Discover at-risk passwords and notify users to change those passwords." + "message": "Upptäck sårbara lösenord och meddela användarna att de ska byta lösenord." }, "dataLastUpdated": { "message": "Data last updated: $DATE$", @@ -27,7 +27,7 @@ "message": "Notified members" }, "allApplicationsWithCount": { - "message": "All applications ($COUNT$)", + "message": "Alla applikationer ($COUNT$)", "placeholders": { "count": { "content": "$1", @@ -81,10 +81,10 @@ "message": "Markera app som kritisk" }, "appsMarkedAsCritical": { - "message": "Apps marked as critical" + "message": "Appar markerade som kritiska" }, "application": { - "message": "Application" + "message": "Applikation" }, "atRiskPasswords": { "message": "At-risk passwords" @@ -163,7 +163,7 @@ "message": "Personal details" }, "identification": { - "message": "Identification" + "message": "Identifikation" }, "contactInfo": { "message": "Contact info" @@ -181,7 +181,7 @@ } }, "itemHistory": { - "message": "Item history" + "message": "Objekthistorik" }, "authenticatorKey": { "message": "Autentiseringsnyckel" @@ -759,7 +759,7 @@ "message": "Kopiera adress" }, "copyPhone": { - "message": "Copy phone" + "message": "Kopiera telefon" }, "copyEmail": { "message": "Copy email" @@ -994,7 +994,7 @@ "message": "Logga in med nyckel" }, "useSingleSignOn": { - "message": "Use single sign-on" + "message": "Använd Single Sign-On" }, "welcomeBack": { "message": "Välkommen tillbaka" @@ -1370,7 +1370,7 @@ "message": "Yubico OTP-säkerhetsnyckel" }, "yubiKeyDesc": { - "message": "Använd en YubiKey för att komma åt ditt konto. Fungerar med YubiKey 4-serien, 5-serien och NEO-enheter." + "message": "Använd en YubiKey 4-, 5- eller NEO-enhet." }, "duoDescV2": { "message": "Ange en kod som genererats av Duo Security.", @@ -1390,7 +1390,7 @@ "message": "Nyckel" }, "webAuthnDesc": { - "message": "Använd en WebAuthn-aktiverad säkerhetsnyckel för att komma åt ditt konto." + "message": "Använd enhetens biometri eller en FIDO2-kompatibel säkerhetsnyckel." }, "webAuthnMigrated": { "message": "(Migrerad från FIDO)" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "Du har 1 inbjudan kvar." - }, "userUsingTwoStep": { "message": "Denna användare använder tvåstegsverifiering för att skydda sitt konto." }, @@ -5733,7 +5730,7 @@ } }, "vaultTimeoutPolicyInEffect1": { - "message": "$HOURS$ hour(s) and $MINUTES$ minute(s) maximum.", + "message": "$HOURS$ timme(ar) och $MINUTES$ minut(er) max.", "placeholders": { "hours": { "content": "$1", @@ -6536,7 +6533,7 @@ "message": "Generera ett e-postalias med en extern vidarebefordranstjänst." }, "forwarderDomainName": { - "message": "Email domain", + "message": "E-postdomän", "description": "Labels the domain name email forwarder service option" }, "forwarderDomainNameHint": { @@ -6708,10 +6705,10 @@ "description": "the text, 'SCIM' and 'API', are acronyms and should not be translated." }, "rotateKey": { - "message": "Rotate key" + "message": "Rotera nyckel" }, "scimApiKey": { - "message": "SCIM API key", + "message": "SCIM API-nyckel", "description": "the text, 'SCIM' and 'API', are acronyms and should not be translated." }, "copyScimUrl": { @@ -6824,7 +6821,7 @@ "message": "Duo two-step login is required for your account." }, "launchDuo": { - "message": "Launch Duo" + "message": "Starta Duo" }, "turnOn": { "message": "Turn on" @@ -9303,19 +9300,19 @@ "message": "Impact of rotating your encryption key" }, "learnMoreAboutEncryptionAlgorithms": { - "message": "Learn more about encryption algorithms" + "message": "Läs mer om krypteringsalgoritmer" }, "learnMoreAboutKDFIterations": { "message": "Learn more about KDF iterations" }, "learnMoreAboutLocalization": { - "message": "Learn more about localization" + "message": "Läs mer om lokalisering" }, "learnMoreAboutWebsiteIcons": { "message": "Learn more about using website icons" }, "learnMoreAboutUserAccess": { - "message": "Learn more about user access" + "message": "Läs mer om användaråtkomst" }, "learnMoreAboutMemberRoles": { "message": "Learn more about member roles and permissions" @@ -9438,7 +9435,7 @@ "message": "Directory integration" }, "passwordLessSso": { - "message": "Passwordless SSO" + "message": "PasswordLess SSO" }, "accountRecovery": { "message": "Kontoåterställning" @@ -9487,10 +9484,10 @@ "message": "Redigera åtkomst" }, "textHelpText": { - "message": "Use text fields for data like security questions" + "message": "Använd textfält för data, som t. ex. säkerhetsfrågor" }, "hiddenHelpText": { - "message": "Use hidden fields for sensitive data like a password" + "message": "Använd dolda fält för känslig data, som t. ex. ett lösenord" }, "checkBoxHelpText": { "message": "Use checkboxes if you'd like to autofill a form's checkbox, like a remember email" @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Radera $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/te/messages.json b/apps/web/src/locales/te/messages.json index 99e5f7bcf32..57cc9a404e1 100644 --- a/apps/web/src/locales/te/messages.json +++ b/apps/web/src/locales/te/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/th/messages.json b/apps/web/src/locales/th/messages.json index e5f976ffe15..8710f029f4e 100644 --- a/apps/web/src/locales/th/messages.json +++ b/apps/web/src/locales/th/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/tr/messages.json b/apps/web/src/locales/tr/messages.json index 5650fb5e082..45bbc83fa35 100644 --- a/apps/web/src/locales/tr/messages.json +++ b/apps/web/src/locales/tr/messages.json @@ -5,14 +5,14 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Parola Riski" }, "discoverAtRiskPasswords": { - "message": "Riskli parolaları tespit edip kullanıcıları bu parolaları değiştirmeleri konusunda bilgilendirin." + "message": "Discover at-risk passwords and notify users to change those passwords." }, "dataLastUpdated": { "message": "Son veri güncellemesi: $DATE$", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "1 davetiyeniz kaldı." - }, "userUsingTwoStep": { "message": "Bu kullanıcı hesabını korumak için iki aşamalı giriş kullanıyor." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/uk/messages.json b/apps/web/src/locales/uk/messages.json index c6aad6ceb28..624fb132a81 100644 --- a/apps/web/src/locales/uk/messages.json +++ b/apps/web/src/locales/uk/messages.json @@ -5,14 +5,14 @@ "criticalApplications": { "message": "Критичні програми" }, - "accessIntelligence": { - "message": "Керування доступом" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Ризиковані паролі" }, "discoverAtRiskPasswords": { - "message": "Дізнавайтеся про ризиковані паролі та сповіщайте користувачів про необхідність їх заміни." + "message": "Discover at-risk passwords and notify users to change those passwords." }, "dataLastUpdated": { "message": "Дані востаннє оновлено: $DATE$", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "У вас залишилось 1 запрошення." - }, "userUsingTwoStep": { "message": "Цей користувач використовує двоетапну перевірку для захисту свого облікового запису." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Невідповідних учасників буде відкликано. Адміністратори зможуть відновити учасників, коли ті покинуть всі інші організації." + }, + "deleteOrganizationUser": { + "message": "Видалити $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "Якщо учасник видаляється, його обліковий запис Bitwarden разом з даними особистого сховища також остаточно видаляється. Дані збірок залишаються в організації. Щоб їх відновити, учасник повинен створити обліковий запис і приєднатися до організації знову.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Видалено $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "Користувача вилучено з організації. Всі пов'язані дані користувача видалено." } } diff --git a/apps/web/src/locales/vi/messages.json b/apps/web/src/locales/vi/messages.json index 83e54637b79..a19cd508793 100644 --- a/apps/web/src/locales/vi/messages.json +++ b/apps/web/src/locales/vi/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "This user is using two-step login to protect their account." }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/apps/web/src/locales/zh_CN/messages.json b/apps/web/src/locales/zh_CN/messages.json index 56fddc0db41..360ca2920f9 100644 --- a/apps/web/src/locales/zh_CN/messages.json +++ b/apps/web/src/locales/zh_CN/messages.json @@ -5,14 +5,14 @@ "criticalApplications": { "message": "关键应用程序" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "密码风险" }, "discoverAtRiskPasswords": { - "message": "发现有风险的密码并通知用户更改这些密码。" + "message": "Discover at-risk passwords and notify users to change those passwords." }, "dataLastUpdated": { "message": "数据最后更新于:$DATE$", @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "您还剩下 1 个邀请。" - }, "userUsingTwoStep": { "message": "此用户正在使用两步登录来保护他们的账户。" }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "不符合要求的成员将被撤销。管理员可以在他们离开所有其他组织后恢复其成员资格。" + }, + "deleteOrganizationUser": { + "message": "删除 $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "当成员被删除时,他们的 Bitwarden 账户和个人密码库数据将被永久删除。集合数据将保留在组织中。要恢复它们,他们必须创建一个账户并重新加入。", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "已删除 $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "该用户已从组织中删除,所有关联的用户数据已被删除。" } } diff --git a/apps/web/src/locales/zh_TW/messages.json b/apps/web/src/locales/zh_TW/messages.json index 571116f4bc1..7673ad3be30 100644 --- a/apps/web/src/locales/zh_TW/messages.json +++ b/apps/web/src/locales/zh_TW/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" @@ -3218,9 +3218,6 @@ } } }, - "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." - }, "userUsingTwoStep": { "message": "此使用者正在使用兩步驟登入保護帳戶。" }, @@ -9557,5 +9554,31 @@ }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." + }, + "deleteOrganizationUser": { + "message": "Delete $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + }, + "description": "Title for the delete organization user dialog" + } + }, + "deleteOrganizationUserWarning": { + "message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.", + "description": "Warning for the delete organization user dialog" + }, + "organizationUserDeleted": { + "message": "Deleted $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "John Doe" + } + } + }, + "organizationUserDeletedDesc": { + "message": "The user was removed from the organization and all associated user data has been deleted." } } diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.html b/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.html index ed58650f211..e72b9ed661a 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.html @@ -12,7 +12,7 @@ }} - + plan.type === PlanType.TeamsMonthly); - const enterprisePlan = this.dialogParams.plans.find( - (plan) => plan.type === PlanType.EnterpriseMonthly, - ); - this.discountPercentage = response.discountPercentage; const discountFactor = this.discountPercentage ? (100 - this.discountPercentage) / 100 : 1; - this.planCards = [ - { - name: this.i18nService.t("planNameTeams"), - cost: teamsPlan.PasswordManager.providerPortalSeatPrice * discountFactor, - type: teamsPlan.type, - plan: teamsPlan, - selected: true, - }, - { - name: this.i18nService.t("planNameEnterprise"), - cost: enterprisePlan.PasswordManager.providerPortalSeatPrice * discountFactor, - type: enterprisePlan.type, - plan: enterprisePlan, - selected: false, - }, - ]; + this.planCards = []; + + for (let i = 0; i < this.providerPlans.length; i++) { + const providerPlan = this.providerPlans[i]; + const plan = this.dialogParams.plans.find((plan) => plan.type === providerPlan.type); + + let planName: string; + switch (plan.productTier) { + case ProductTierType.Teams: { + planName = this.i18nService.t("planNameTeams"); + break; + } + case ProductTierType.Enterprise: { + planName = this.i18nService.t("planNameEnterprise"); + break; + } + } + + this.planCards.push({ + name: planName, + cost: plan.PasswordManager.providerPortalSeatPrice * discountFactor, + type: plan.type, + plan: plan, + selected: i === 0, + }); + } this.loading = false; } diff --git a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription-status.component.html b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription-status.component.html index 6c4bf422f7a..f08dbf0c37a 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription-status.component.html +++ b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription-status.component.html @@ -4,7 +4,7 @@ {{ "billingPlan" | i18n }} - {{ "providerPlan" | i18n }} + {{ plan | i18n }} {{ data.status.label }} diff --git a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription-status.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription-status.component.ts index c3ad875136e..dea7d4ca197 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription-status.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription-status.component.ts @@ -1,6 +1,7 @@ import { DatePipe } from "@angular/common"; import { Component, Input } from "@angular/core"; +import { ProviderType } from "@bitwarden/common/admin-console/enums"; import { ProviderSubscriptionResponse } from "@bitwarden/common/billing/models/response/provider-subscription-response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -32,6 +33,15 @@ export class ProviderSubscriptionStatusComponent { private i18nService: I18nService, ) {} + get plan(): string { + switch (this.subscription.providerType) { + case ProviderType.Msp: + return "managedServiceProvider"; + case ProviderType.MultiOrganizationEnterprise: + return "multiOrganizationEnterprise"; + } + } + get status(): string { if (this.subscription.cancelAt && this.subscription.status === "active") { return "pending_cancellation"; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html index a82e35afb60..31746e7601c 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html @@ -1,3 +1,24 @@ + + + {{ freeTrial.message }} + + {{ "routeToPaymentMethodTrigger" | i18n }} + + + 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 7073b4c289f..bf2dbb76ad3 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 @@ -1,5 +1,5 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; -import { ActivatedRoute } from "@angular/router"; +import { ActivatedRoute, Router } from "@angular/router"; import { map, Observable, @@ -12,14 +12,20 @@ import { take, share, firstValueFrom, - concatMap, + of, + filter, } from "rxjs"; +import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService } from "@bitwarden/components"; +import { TrialFlowService } from "@bitwarden/web-vault/app/billing/services/trial-flow.service"; +import { FreeTrial } from "@bitwarden/web-vault/app/core/types/free-trial"; import { OrganizationCounts } from "../models/view/counts.view"; import { ProjectListView } from "../models/view/project-list.view"; @@ -81,6 +87,8 @@ export class OverviewComponent implements OnInit, OnDestroy { protected showOnboarding = false; protected loading = true; protected organizationEnabled = false; + protected organization: Organization; + protected i18n: I18nPipe; protected onboardingTasks$: Observable; protected view$: Observable<{ @@ -91,6 +99,7 @@ export class OverviewComponent implements OnInit, OnDestroy { tasks: OrganizationTasks; counts: OrganizationCounts; }>; + protected freeTrial$: Observable; constructor( private route: ActivatedRoute, @@ -104,6 +113,10 @@ export class OverviewComponent implements OnInit, OnDestroy { private i18nService: I18nService, private smOnboardingTasksService: SMOnboardingTasksService, private logService: LogService, + private router: Router, + + private organizationApiService: OrganizationApiServiceAbstraction, + private trialFlowService: TrialFlowService, ) {} ngOnInit() { @@ -114,18 +127,35 @@ export class OverviewComponent implements OnInit, OnDestroy { distinctUntilChanged(), ); - orgId$ - .pipe( - concatMap(async (orgId) => await this.organizationService.get(orgId)), - takeUntil(this.destroy$), - ) - .subscribe((org) => { - this.organizationId = org.id; - this.organizationName = org.name; - this.userIsAdmin = org.isAdmin; - this.loading = true; - this.organizationEnabled = org.enabled; - }); + const org$ = orgId$.pipe(switchMap((orgId) => this.organizationService.get(orgId))); + + org$.pipe(takeUntil(this.destroy$)).subscribe((org) => { + this.organizationId = org.id; + this.organization = org; + this.organizationName = org.name; + this.userIsAdmin = org.isAdmin; + this.loading = true; + this.organizationEnabled = org.enabled; + }); + + this.freeTrial$ = org$.pipe( + filter((org) => org.isOwner), + switchMap((org) => + combineLatest([ + of(org), + this.organizationApiService.getSubscription(org.id), + this.organizationApiService.getBilling(org.id), + ]), + ), + map(([org, sub, billing]) => { + return this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues( + org, + sub, + billing?.paymentSource, + ); + }), + takeUntil(this.destroy$), + ); const projects$ = combineLatest([ orgId$, @@ -197,6 +227,15 @@ export class OverviewComponent implements OnInit, OnDestroy { }); } + async navigateToPaymentMethod() { + await this.router.navigate( + ["organizations", `${this.organizationId}`, "billing", "payment-method"], + { + state: { launchPaymentModalAutomatically: true }, + }, + ); + } + ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.module.ts index 72039f532ae..b9c09a0d671 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.module.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.module.ts @@ -1,5 +1,7 @@ import { NgModule } from "@angular/core"; +import { BannerModule } from "@bitwarden/components"; + import { OnboardingModule } from "../../../../../../apps/web/src/app/shared/components/onboarding/onboarding.module"; import { SecretsManagerSharedModule } from "../shared/sm-shared.module"; @@ -8,7 +10,7 @@ import { OverviewComponent } from "./overview.component"; import { SectionComponent } from "./section.component"; @NgModule({ - imports: [SecretsManagerSharedModule, OverviewRoutingModule, OnboardingModule], + imports: [SecretsManagerSharedModule, OverviewRoutingModule, OnboardingModule, BannerModule], declarations: [OverviewComponent, SectionComponent], providers: [], }) diff --git a/libs/angular/src/vault/components/add-edit.component.ts b/libs/angular/src/vault/components/add-edit.component.ts index aa661236f4c..952f2071e91 100644 --- a/libs/angular/src/vault/components/add-edit.component.ts +++ b/libs/angular/src/vault/components/add-edit.component.ts @@ -14,7 +14,8 @@ import { OrganizationUserStatusType, PolicyType } from "@bitwarden/common/admin- import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils"; -import { EventType } from "@bitwarden/common/enums"; +import { ClientType, EventType } from "@bitwarden/common/enums"; +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"; @@ -36,6 +37,7 @@ import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view" import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view"; +import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService } from "@bitwarden/components"; import { PasswordRepromptService } from "@bitwarden/vault"; @@ -71,6 +73,7 @@ export class AddEditComponent implements OnInit, OnDestroy { restorePromise: Promise; checkPasswordPromise: Promise; showPassword = false; + showPrivateKey = false; showTotpSeed = false; showCardNumber = false; showCardCode = false; @@ -134,6 +137,7 @@ export class AddEditComponent implements OnInit, OnDestroy { { name: i18nService.t("typeIdentity"), value: CipherType.Identity }, { name: i18nService.t("typeSecureNote"), value: CipherType.SecureNote }, ]; + this.cardBrandOptions = [ { name: "-- " + i18nService.t("select") + " --", value: null }, { name: "Visa", value: "Visa" }, @@ -200,6 +204,11 @@ export class AddEditComponent implements OnInit, OnDestroy { this.writeableCollections = await this.loadCollections(); this.canUseReprompt = await this.passwordRepromptService.enabled(); + + const sshKeysEnabled = await this.configService.getFeatureFlag(FeatureFlag.SSHKeyVaultItem); + if (this.platformUtilsService.getClientType() == ClientType.Desktop && sshKeysEnabled) { + this.typeOptions.push({ name: this.i18nService.t("typeSshKey"), value: CipherType.SshKey }); + } } ngOnDestroy() { @@ -279,6 +288,7 @@ export class AddEditComponent implements OnInit, OnDestroy { this.cipher.identity = new IdentityView(); this.cipher.secureNote = new SecureNoteView(); this.cipher.secureNote.type = SecureNoteType.Generic; + this.cipher.sshKey = new SshKeyView(); this.cipher.reprompt = CipherRepromptType.None; } } @@ -601,6 +611,10 @@ export class AddEditComponent implements OnInit, OnDestroy { } } + togglePrivateKey() { + this.showPrivateKey = !this.showPrivateKey; + } + toggleUriOptions(uri: LoginUriView) { const u = uri as any; u.showOptions = u.showOptions == null && uri.match != null ? false : !u.showOptions; diff --git a/libs/angular/src/vault/components/view.component.ts b/libs/angular/src/vault/components/view.component.ts index c2666056705..3226e1292bb 100644 --- a/libs/angular/src/vault/components/view.component.ts +++ b/libs/angular/src/vault/components/view.component.ts @@ -60,6 +60,7 @@ export class ViewComponent implements OnDestroy, OnInit { showPasswordCount: boolean; showCardNumber: boolean; showCardCode: boolean; + showPrivateKey: boolean; canAccessPremium: boolean; showPremiumRequiredTotp: boolean; totpCode: string; @@ -325,6 +326,10 @@ export class ViewComponent implements OnDestroy, OnInit { } } + togglePrivateKey() { + this.showPrivateKey = !this.showPrivateKey; + } + async checkPassword() { if ( this.cipher.login == null || diff --git a/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts index e66fc0cf12a..051275f7945 100644 --- a/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts @@ -6,6 +6,7 @@ import { SecretVerificationRequest } from "../../../auth/models/request/secret-v import { ApiKeyResponse } from "../../../auth/models/response/api-key.response"; import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response"; import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request"; +import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request"; import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request"; import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request"; import { PaymentRequest } from "../../../billing/models/request/payment.request"; @@ -40,6 +41,9 @@ export class OrganizationApiServiceAbstraction { getLicense: (id: string, installationId: string) => Promise; getAutoEnrollStatus: (identifier: string) => Promise; create: (request: OrganizationCreateRequest) => Promise; + createWithoutPayment: ( + request: OrganizationNoPaymentMethodCreateRequest, + ) => Promise; createLicense: (data: FormData) => Promise; save: (id: string, request: OrganizationUpdateRequest) => Promise; updatePayment: (id: string, request: PaymentRequest) => Promise; diff --git a/libs/common/src/admin-console/enums/provider-type.enum.ts b/libs/common/src/admin-console/enums/provider-type.enum.ts index 5f81c338f0e..d802c659f6f 100644 --- a/libs/common/src/admin-console/enums/provider-type.enum.ts +++ b/libs/common/src/admin-console/enums/provider-type.enum.ts @@ -1,4 +1,5 @@ export enum ProviderType { Msp = 0, Reseller = 1, + MultiOrganizationEnterprise = 2, } diff --git a/libs/common/src/admin-console/models/request/organization-create.request.ts b/libs/common/src/admin-console/models/request/organization-create.request.ts index 9f0441c4340..98f19bebaf4 100644 --- a/libs/common/src/admin-console/models/request/organization-create.request.ts +++ b/libs/common/src/admin-console/models/request/organization-create.request.ts @@ -1,32 +1,7 @@ -import { PaymentMethodType, PlanType } from "../../../billing/enums"; -import { InitiationPath } from "../../../models/request/reference-event.request"; +import { PaymentMethodType } from "../../../billing/enums"; +import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request"; -import { OrganizationKeysRequest } from "./organization-keys.request"; - -export class OrganizationCreateRequest { - name: string; - businessName: string; - billingEmail: string; - planType: PlanType; - key: string; - keys: OrganizationKeysRequest; +export class OrganizationCreateRequest extends OrganizationNoPaymentMethodCreateRequest { paymentMethodType: PaymentMethodType; paymentToken: string; - additionalSeats: number; - maxAutoscaleSeats: number; - additionalStorageGb: number; - premiumAccessAddon: boolean; - collectionName: string; - taxIdNumber: string; - billingAddressLine1: string; - billingAddressLine2: string; - billingAddressCity: string; - billingAddressState: string; - billingAddressPostalCode: string; - billingAddressCountry: string; - useSecretsManager: boolean; - additionalSmSeats: number; - additionalServiceAccounts: number; - isFromSecretsManagerTrial: boolean; - initiationPath: InitiationPath; } diff --git a/libs/common/src/admin-console/services/organization/organization-api.service.ts b/libs/common/src/admin-console/services/organization/organization-api.service.ts index 2ff4f2321a3..a2259d73cc5 100644 --- a/libs/common/src/admin-console/services/organization/organization-api.service.ts +++ b/libs/common/src/admin-console/services/organization/organization-api.service.ts @@ -7,6 +7,7 @@ import { SecretVerificationRequest } from "../../../auth/models/request/secret-v import { ApiKeyResponse } from "../../../auth/models/response/api-key.response"; import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response"; import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request"; +import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request"; import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request"; import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request"; import { PaymentRequest } from "../../../billing/models/request/payment.request"; @@ -107,6 +108,21 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction return new OrganizationResponse(r); } + async createWithoutPayment( + request: OrganizationNoPaymentMethodCreateRequest, + ): Promise { + const r = await this.apiService.send( + "POST", + "/organizations/create-without-payment", + request, + true, + true, + ); + // Forcing a sync will notify organization service that they need to repull + await this.syncService.fullSync(true); + return new OrganizationResponse(r); + } + async createLicense(data: FormData): Promise { const r = await this.apiService.send( "POST", diff --git a/libs/common/src/billing/abstractions/organization-billing.service.ts b/libs/common/src/billing/abstractions/organization-billing.service.ts index d19724b600a..72902baa30e 100644 --- a/libs/common/src/billing/abstractions/organization-billing.service.ts +++ b/libs/common/src/billing/abstractions/organization-billing.service.ts @@ -44,4 +44,8 @@ export abstract class OrganizationBillingServiceAbstraction { purchaseSubscription: (subscription: SubscriptionInformation) => Promise; startFree: (subscription: SubscriptionInformation) => Promise; + + purchaseSubscriptionNoPaymentMethod: ( + subscription: SubscriptionInformation, + ) => Promise; } diff --git a/libs/common/src/billing/models/request/organization-no-payment-method-create-request.ts b/libs/common/src/billing/models/request/organization-no-payment-method-create-request.ts new file mode 100644 index 00000000000..b48caec8dfc --- /dev/null +++ b/libs/common/src/billing/models/request/organization-no-payment-method-create-request.ts @@ -0,0 +1,29 @@ +import { OrganizationKeysRequest } from "../../../admin-console/models/request/organization-keys.request"; +import { InitiationPath } from "../../../models/request/reference-event.request"; +import { PlanType } from "../../enums"; + +export class OrganizationNoPaymentMethodCreateRequest { + name: string; + businessName: string; + billingEmail: string; + planType: PlanType; + key: string; + keys: OrganizationKeysRequest; + additionalSeats: number; + maxAutoscaleSeats: number; + additionalStorageGb: number; + premiumAccessAddon: boolean; + collectionName: string; + taxIdNumber: string; + billingAddressLine1: string; + billingAddressLine2: string; + billingAddressCity: string; + billingAddressState: string; + billingAddressPostalCode: string; + billingAddressCountry: string; + useSecretsManager: boolean; + additionalSmSeats: number; + additionalServiceAccounts: number; + isFromSecretsManagerTrial: boolean; + initiationPath: InitiationPath; +} diff --git a/libs/common/src/billing/models/response/organization-billing-metadata.response.ts b/libs/common/src/billing/models/response/organization-billing-metadata.response.ts index 3d846e6c987..ae6d1ac92c1 100644 --- a/libs/common/src/billing/models/response/organization-billing-metadata.response.ts +++ b/libs/common/src/billing/models/response/organization-billing-metadata.response.ts @@ -4,11 +4,13 @@ export class OrganizationBillingMetadataResponse extends BaseResponse { isEligibleForSelfHost: boolean; isManaged: boolean; isOnSecretsManagerStandalone: boolean; + isSubscriptionUnpaid: boolean; constructor(response: any) { super(response); this.isEligibleForSelfHost = this.getResponseProperty("IsEligibleForSelfHost"); this.isManaged = this.getResponseProperty("IsManaged"); this.isOnSecretsManagerStandalone = this.getResponseProperty("IsOnSecretsManagerStandalone"); + this.isSubscriptionUnpaid = this.getResponseProperty("IsSubscriptionUnpaid"); } } diff --git a/libs/common/src/billing/models/response/provider-subscription-response.ts b/libs/common/src/billing/models/response/provider-subscription-response.ts index 2dc9d4281de..2ecf988addd 100644 --- a/libs/common/src/billing/models/response/provider-subscription-response.ts +++ b/libs/common/src/billing/models/response/provider-subscription-response.ts @@ -1,3 +1,5 @@ +import { ProviderType } from "@bitwarden/common/admin-console/enums"; +import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; import { SubscriptionSuspensionResponse } from "@bitwarden/common/billing/models/response/subscription-suspension.response"; import { TaxInfoResponse } from "@bitwarden/common/billing/models/response/tax-info.response"; @@ -13,6 +15,7 @@ export class ProviderSubscriptionResponse extends BaseResponse { taxInformation?: TaxInfoResponse; cancelAt?: string; suspension?: SubscriptionSuspensionResponse; + providerType: ProviderType; constructor(response: any) { super(response); @@ -34,6 +37,7 @@ export class ProviderSubscriptionResponse extends BaseResponse { if (suspension != null) { this.suspension = new SubscriptionSuspensionResponse(suspension); } + this.providerType = this.getResponseProperty("providerType"); } } @@ -44,6 +48,8 @@ export class ProviderPlanResponse extends BaseResponse { purchasedSeats: number; cost: number; cadence: string; + type: PlanType; + productTier: ProductTierType; constructor(response: any) { super(response); @@ -53,5 +59,7 @@ export class ProviderPlanResponse extends BaseResponse { this.purchasedSeats = this.getResponseProperty("PurchasedSeats"); this.cost = this.getResponseProperty("Cost"); this.cadence = this.getResponseProperty("Cadence"); + this.type = this.getResponseProperty("Type"); + this.productTier = this.getResponseProperty("ProductTier"); } } diff --git a/libs/common/src/billing/services/organization-billing.service.ts b/libs/common/src/billing/services/organization-billing.service.ts index eebea0ca74e..efc36278532 100644 --- a/libs/common/src/billing/services/organization-billing.service.ts +++ b/libs/common/src/billing/services/organization-billing.service.ts @@ -17,6 +17,7 @@ import { SubscriptionInformation, } from "../abstractions/organization-billing.service"; import { PlanType } from "../enums"; +import { OrganizationNoPaymentMethodCreateRequest } from "../models/request/organization-no-payment-method-create-request"; interface OrganizationKeys { encryptedKey: EncString; @@ -77,6 +78,28 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs return response; } + async purchaseSubscriptionNoPaymentMethod( + subscription: SubscriptionInformation, + ): Promise { + const request = new OrganizationNoPaymentMethodCreateRequest(); + + const organizationKeys = await this.makeOrganizationKeys(); + + this.setOrganizationKeys(request, organizationKeys); + + this.setOrganizationInformation(request, subscription.organization); + + this.setPlanInformation(request, subscription.plan); + + const response = await this.organizationApiService.createWithoutPayment(request); + + await this.apiService.refreshIdentityToken(); + + await this.syncService.fullSync(true); + + return response; + } + private async makeOrganizationKeys(): Promise { const [encryptedKey, key] = await this.keyService.makeOrgKey(); const [publicKey, encryptedPrivateKey] = await this.keyService.makeKeyPair(key); @@ -106,7 +129,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs } private setOrganizationInformation( - request: OrganizationCreateRequest, + request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest, information: OrganizationInformation, ): void { request.name = information.name; @@ -115,7 +138,10 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs request.initiationPath = information.initiationPath; } - private setOrganizationKeys(request: OrganizationCreateRequest, keys: OrganizationKeys): void { + private setOrganizationKeys( + request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest, + keys: OrganizationKeys, + ): void { request.key = keys.encryptedKey.encryptedString; request.keys = new OrganizationKeysRequest( keys.publicKey, @@ -146,7 +172,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs } private setPlanInformation( - request: OrganizationCreateRequest, + request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest, information: PlanInformation, ): void { request.planType = information.type; diff --git a/libs/common/src/enums/event-type.enum.ts b/libs/common/src/enums/event-type.enum.ts index c72fb80de4d..51b324bb434 100644 --- a/libs/common/src/enums/event-type.enum.ts +++ b/libs/common/src/enums/event-type.enum.ts @@ -56,6 +56,8 @@ export enum EventType { OrganizationUser_Restored = 1512, OrganizationUser_ApprovedAuthRequest = 1513, OrganizationUser_RejectedAuthRequest = 1514, + OrganizationUser_Deleted = 1515, + OrganizationUser_Left = 1516, Organization_Updated = 1600, Organization_PurgedVault = 1601, diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index ea016e34350..d36aea241d5 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -27,6 +27,8 @@ export enum FeatureFlag { EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill", DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2", AccountDeprovisioning = "pm-10308-account-deprovisioning", + SSHKeyVaultItem = "ssh-key-vault-item", + SSHAgent = "ssh-agent", NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements", AC2476_DeprecateStripeSourcesAPI = "AC-2476-deprecate-stripe-sources-api", CipherKeyEncryption = "cipher-key-encryption", @@ -35,6 +37,9 @@ export enum FeatureFlag { AccessIntelligence = "pm-13227-access-intelligence", Pm13322AddPolicyDefinitions = "pm-13322-add-policy-definitions", LimitCollectionCreationDeletionSplit = "pm-10863-limit-collection-creation-deletion-split", + CriticalApps = "pm-14466-risk-insights-critical-application", + TrialPaymentOptional = "PM-8163-trial-payment", + SecurityTasks = "security-tasks", } export type AllowedFeatureFlagTypes = boolean | number | string; @@ -72,6 +77,8 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE, [FeatureFlag.DelayFido2PageScriptInitWithinMv2]: FALSE, [FeatureFlag.AccountDeprovisioning]: FALSE, + [FeatureFlag.SSHKeyVaultItem]: FALSE, + [FeatureFlag.SSHAgent]: FALSE, [FeatureFlag.NotificationBarAddLoginImprovements]: FALSE, [FeatureFlag.AC2476_DeprecateStripeSourcesAPI]: FALSE, [FeatureFlag.CipherKeyEncryption]: FALSE, @@ -80,6 +87,9 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.AccessIntelligence]: FALSE, [FeatureFlag.Pm13322AddPolicyDefinitions]: FALSE, [FeatureFlag.LimitCollectionCreationDeletionSplit]: FALSE, + [FeatureFlag.CriticalApps]: FALSE, + [FeatureFlag.TrialPaymentOptional]: FALSE, + [FeatureFlag.SecurityTasks]: FALSE, } satisfies Record; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue; diff --git a/libs/common/src/models/export/cipher.export.ts b/libs/common/src/models/export/cipher.export.ts index 64583f7fcef..432a2d4e250 100644 --- a/libs/common/src/models/export/cipher.export.ts +++ b/libs/common/src/models/export/cipher.export.ts @@ -10,6 +10,7 @@ import { IdentityExport } from "./identity.export"; import { LoginExport } from "./login.export"; import { PasswordHistoryExport } from "./password-history.export"; import { SecureNoteExport } from "./secure-note.export"; +import { SshKeyExport } from "./ssh-key.export"; import { safeGetString } from "./utils"; export class CipherExport { @@ -27,6 +28,7 @@ export class CipherExport { req.secureNote = null; req.card = null; req.identity = null; + req.sshKey = null; req.reprompt = CipherRepromptType.None; req.passwordHistory = []; req.creationDate = null; @@ -67,6 +69,8 @@ export class CipherExport { case CipherType.Identity: view.identity = IdentityExport.toView(req.identity); break; + case CipherType.SshKey: + view.sshKey = SshKeyExport.toView(req.sshKey); } if (req.passwordHistory != null) { @@ -108,6 +112,9 @@ export class CipherExport { case CipherType.Identity: domain.identity = IdentityExport.toDomain(req.identity); break; + case CipherType.SshKey: + domain.sshKey = SshKeyExport.toDomain(req.sshKey); + break; } if (req.passwordHistory != null) { @@ -132,6 +139,7 @@ export class CipherExport { secureNote: SecureNoteExport; card: CardExport; identity: IdentityExport; + sshKey: SshKeyExport; reprompt: CipherRepromptType; passwordHistory: PasswordHistoryExport[] = null; revisionDate: Date = null; @@ -171,6 +179,9 @@ export class CipherExport { case CipherType.Identity: this.identity = new IdentityExport(o.identity); break; + case CipherType.SshKey: + this.sshKey = new SshKeyExport(o.sshKey); + break; } if (o.passwordHistory != null) { diff --git a/libs/common/src/models/export/ssh-key.export.ts b/libs/common/src/models/export/ssh-key.export.ts new file mode 100644 index 00000000000..86683e97e20 --- /dev/null +++ b/libs/common/src/models/export/ssh-key.export.ts @@ -0,0 +1,44 @@ +import { SshKeyView as SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view"; + +import { EncString } from "../../platform/models/domain/enc-string"; +import { SshKey as SshKeyDomain } from "../../vault/models/domain/ssh-key"; + +import { safeGetString } from "./utils"; + +export class SshKeyExport { + static template(): SshKeyExport { + const req = new SshKeyExport(); + req.privateKey = ""; + req.publicKey = ""; + req.keyFingerprint = ""; + return req; + } + + static toView(req: SshKeyExport, view = new SshKeyView()) { + view.privateKey = req.privateKey; + view.publicKey = req.publicKey; + view.keyFingerprint = req.keyFingerprint; + return view; + } + + static toDomain(req: SshKeyExport, domain = new SshKeyDomain()) { + domain.privateKey = req.privateKey != null ? new EncString(req.privateKey) : null; + domain.publicKey = req.publicKey != null ? new EncString(req.publicKey) : null; + domain.keyFingerprint = req.keyFingerprint != null ? new EncString(req.keyFingerprint) : null; + return domain; + } + + privateKey: string; + publicKey: string; + keyFingerprint: string; + + constructor(o?: SshKeyView | SshKeyDomain) { + if (o == null) { + return; + } + + this.privateKey = safeGetString(o.privateKey); + this.publicKey = safeGetString(o.publicKey); + this.keyFingerprint = safeGetString(o.keyFingerprint); + } +} diff --git a/libs/common/src/platform/services/fido2/fido2-utils.spec.ts b/libs/common/src/platform/services/fido2/fido2-utils.spec.ts index a05eab52305..9bb4ed0a4c5 100644 --- a/libs/common/src/platform/services/fido2/fido2-utils.spec.ts +++ b/libs/common/src/platform/services/fido2/fido2-utils.spec.ts @@ -4,6 +4,36 @@ describe("Fido2 Utils", () => { const asciiHelloWorldArray = [104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100]; const b64HelloWorldString = "aGVsbG8gd29ybGQ="; + describe("bufferSourceToUint8Array(..)", () => { + it("should convert an ArrayBuffer", () => { + const buffer = new Uint8Array(asciiHelloWorldArray).buffer; + const out = Fido2Utils.bufferSourceToUint8Array(buffer); + expect(out).toEqual(new Uint8Array(asciiHelloWorldArray)); + }); + it("should convert an ArrayBuffer slice", () => { + const buffer = new Uint8Array(asciiHelloWorldArray).buffer.slice(8); + const out = Fido2Utils.bufferSourceToUint8Array(buffer); + expect(out).toEqual(new Uint8Array([114, 108, 100])); // 8th byte onwards + }); + it("should pass through an Uint8Array", () => { + const typedArray = new Uint8Array(asciiHelloWorldArray); + const out = Fido2Utils.bufferSourceToUint8Array(typedArray); + expect(out).toEqual(new Uint8Array(asciiHelloWorldArray)); + }); + it("should preserve the view of TypedArray", () => { + const buffer = new Uint8Array(asciiHelloWorldArray).buffer; + const input = new Uint8Array(buffer, 8, 1); + const out = Fido2Utils.bufferSourceToUint8Array(input); + expect(out).toEqual(new Uint8Array([114])); + }); + it("should convert different TypedArrays", () => { + const buffer = new Uint8Array(asciiHelloWorldArray).buffer; + const input = new Uint16Array(buffer, 8, 1); + const out = Fido2Utils.bufferSourceToUint8Array(input); + expect(out).toEqual(new Uint8Array([114, 108])); + }); + }); + describe("fromBufferToB64(...)", () => { it("should convert an ArrayBuffer to a b64 string", () => { const buffer = new Uint8Array(asciiHelloWorldArray).buffer; diff --git a/libs/common/src/platform/services/fido2/fido2-utils.ts b/libs/common/src/platform/services/fido2/fido2-utils.ts index c3c3eba246b..58034912978 100644 --- a/libs/common/src/platform/services/fido2/fido2-utils.ts +++ b/libs/common/src/platform/services/fido2/fido2-utils.ts @@ -1,13 +1,6 @@ export class Fido2Utils { static bufferToString(bufferSource: BufferSource): string { - let buffer: Uint8Array; - if (bufferSource instanceof ArrayBuffer || bufferSource.buffer === undefined) { - buffer = new Uint8Array(bufferSource as ArrayBuffer); - } else { - buffer = new Uint8Array(bufferSource.buffer); - } - - return Fido2Utils.fromBufferToB64(buffer) + return Fido2Utils.fromBufferToB64(Fido2Utils.bufferSourceToUint8Array(bufferSource)) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=/g, ""); @@ -18,12 +11,10 @@ export class Fido2Utils { } static bufferSourceToUint8Array(bufferSource: BufferSource): Uint8Array { - if (bufferSource instanceof Uint8Array) { - return bufferSource; - } else if (Fido2Utils.isArrayBuffer(bufferSource)) { + if (Fido2Utils.isArrayBuffer(bufferSource)) { return new Uint8Array(bufferSource); } else { - return new Uint8Array(bufferSource.buffer); + return new Uint8Array(bufferSource.buffer, bufferSource.byteOffset, bufferSource.byteLength); } } diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 2d4a0522636..0c508bfeb88 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -584,7 +584,7 @@ export class ApiService implements ApiServiceAbstraction { } putCipherCollectionsAdmin(id: string, request: CipherCollectionsRequest): Promise { - return this.send("PUT", "/ciphers/" + id + "/collections-admin", request, true, false); + return this.send("PUT", "/ciphers/" + id + "/collections-admin", request, true, true); } postPurgeCiphers( @@ -1886,7 +1886,7 @@ export class ApiService implements ApiServiceAbstraction { }); if (flagEnabled("prereleaseBuild")) { - headers.set("Is-Prerelease", "true"); + headers.set("Is-Prerelease", "1"); } if (this.customUserAgent != null) { headers.set("User-Agent", this.customUserAgent); diff --git a/libs/common/src/tools/state/user-state-subject.spec.ts b/libs/common/src/tools/state/user-state-subject.spec.ts index 9f5475df9de..ee78a5c048b 100644 --- a/libs/common/src/tools/state/user-state-subject.spec.ts +++ b/libs/common/src/tools/state/user-state-subject.spec.ts @@ -373,7 +373,11 @@ describe("UserStateSubject", () => { singleUserId$.next(SomeUser); await awaitAsync(); - expect(state.nextMock).toHaveBeenCalledWith({ foo: "next" }); + expect(state.nextMock).toHaveBeenCalledWith({ + foo: "next", + // FIXME: don't leak this detail into the test + "$^$ALWAYS_UPDATE_KLUDGE_PROPERTY$^$": 0, + }); }); it("waits to evaluate `UserState.update` until singleUserEncryptor$ emits", async () => { @@ -394,7 +398,13 @@ describe("UserStateSubject", () => { await awaitAsync(); const encrypted = { foo: "encrypt(next)" }; - expect(state.nextMock).toHaveBeenCalledWith({ id: null, secret: encrypted, disclosed: null }); + expect(state.nextMock).toHaveBeenCalledWith({ + id: null, + secret: encrypted, + disclosed: null, + // FIXME: don't leak this detail into the test + "$^$ALWAYS_UPDATE_KLUDGE_PROPERTY$^$": 0, + }); }); it("applies dynamic constraints", async () => { diff --git a/libs/common/src/tools/state/user-state-subject.ts b/libs/common/src/tools/state/user-state-subject.ts index 845ab25c808..0b562cc7a1f 100644 --- a/libs/common/src/tools/state/user-state-subject.ts +++ b/libs/common/src/tools/state/user-state-subject.ts @@ -43,6 +43,23 @@ import { UserStateSubjectDependencies } from "./user-state-subject-dependencies" type Constrained = { constraints: Readonly>; state: State }; +// FIXME: The subject should always repeat the value when it's own `next` method is called. +// +// Chrome StateService only calls `next` when the underlying values changes. When enforcing, +// say, a minimum constraint, any value beneath the minimum becomes the minimum. This prevents +// invalid data received in sequence from calling `next` because the state provider doesn't +// emit. +// +// The hack is pretty simple. Insert arbitrary data into the saved data to ensure +// that it *always* changes. +// +// Any real fix will be fairly complex because it needs to recognize *fast* when it +// is waiting. Alternatively, the kludge could become a format properly fed by random noise. +// +// NOTE: this only matters for plaintext objects; encrypted fields change with every +// update b/c their IVs change. +const ALWAYS_UPDATE_KLUDGE = "$^$ALWAYS_UPDATE_KLUDGE_PROPERTY$^$"; + /** * Adapt a state provider to an rxjs subject. * @@ -420,8 +437,25 @@ export class UserStateSubject< private inputSubscription: Unsubscribable; private outputSubscription: Unsubscribable; + private counter = 0; + private onNext(value: unknown) { - this.state.update(() => value).catch((e: any) => this.onError(e)); + this.state + .update(() => { + if (typeof value === "object") { + // related: ALWAYS_UPDATE_KLUDGE FIXME + const counter = this.counter++; + if (counter > Number.MAX_SAFE_INTEGER) { + this.counter = 0; + } + + const kludge = value as any; + kludge[ALWAYS_UPDATE_KLUDGE] = counter; + } + + return value; + }) + .catch((e: any) => this.onError(e)); } private onError(value: any) { diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 444c922fe31..5221f4cf0a6 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -119,7 +119,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider Promise; + saveCollectionsWithServerAdmin: (cipher: Cipher) => Promise; /** * Bulk update collections for many ciphers with the server * @param orgId diff --git a/libs/common/src/vault/enums/cipher-type.ts b/libs/common/src/vault/enums/cipher-type.ts index cce7874d667..0b7bbf1ee17 100644 --- a/libs/common/src/vault/enums/cipher-type.ts +++ b/libs/common/src/vault/enums/cipher-type.ts @@ -3,4 +3,5 @@ export enum CipherType { SecureNote = 2, Card = 3, Identity = 4, + SshKey = 5, } diff --git a/libs/common/src/vault/icon/build-cipher-icon.ts b/libs/common/src/vault/icon/build-cipher-icon.ts index 9e6e671f44d..78e6ecd7b4f 100644 --- a/libs/common/src/vault/icon/build-cipher-icon.ts +++ b/libs/common/src/vault/icon/build-cipher-icon.ts @@ -67,6 +67,9 @@ export function buildCipherIcon(iconsServerUrl: string, cipher: CipherView, show case CipherType.Identity: icon = "bwi-id-card"; break; + case CipherType.SshKey: + icon = "bwi-key"; + break; default: break; } diff --git a/libs/common/src/vault/models/api/ssh-key.api.ts b/libs/common/src/vault/models/api/ssh-key.api.ts new file mode 100644 index 00000000000..e14f72bbc6a --- /dev/null +++ b/libs/common/src/vault/models/api/ssh-key.api.ts @@ -0,0 +1,17 @@ +import { BaseResponse } from "../../../models/response/base.response"; + +export class SshKeyApi extends BaseResponse { + privateKey: string; + publicKey: string; + keyFingerprint: string; + + constructor(data: any = null) { + super(data); + if (data == null) { + return; + } + this.privateKey = this.getResponseProperty("PrivateKey"); + this.publicKey = this.getResponseProperty("PublicKey"); + this.keyFingerprint = this.getResponseProperty("KeyFingerprint"); + } +} diff --git a/libs/common/src/vault/models/data/cipher.data.ts b/libs/common/src/vault/models/data/cipher.data.ts index f8db7186d61..476c651f3ae 100644 --- a/libs/common/src/vault/models/data/cipher.data.ts +++ b/libs/common/src/vault/models/data/cipher.data.ts @@ -11,6 +11,7 @@ import { IdentityData } from "./identity.data"; import { LoginData } from "./login.data"; import { PasswordHistoryData } from "./password-history.data"; import { SecureNoteData } from "./secure-note.data"; +import { SshKeyData } from "./ssh-key.data"; export class CipherData { id: string; @@ -28,6 +29,7 @@ export class CipherData { secureNote?: SecureNoteData; card?: CardData; identity?: IdentityData; + sshKey?: SshKeyData; fields?: FieldData[]; attachments?: AttachmentData[]; passwordHistory?: PasswordHistoryData[]; @@ -72,6 +74,9 @@ export class CipherData { case CipherType.Identity: this.identity = new IdentityData(response.identity); break; + case CipherType.SshKey: + this.sshKey = new SshKeyData(response.sshKey); + break; default: break; } diff --git a/libs/common/src/vault/models/data/ssh-key.data.ts b/libs/common/src/vault/models/data/ssh-key.data.ts new file mode 100644 index 00000000000..32b6ec994f3 --- /dev/null +++ b/libs/common/src/vault/models/data/ssh-key.data.ts @@ -0,0 +1,17 @@ +import { SshKeyApi } from "../api/ssh-key.api"; + +export class SshKeyData { + privateKey: string; + publicKey: string; + keyFingerprint: string; + + constructor(data?: SshKeyApi) { + if (data == null) { + return; + } + + this.privateKey = data.privateKey; + this.publicKey = data.publicKey; + this.keyFingerprint = data.keyFingerprint; + } +} diff --git a/libs/common/src/vault/models/domain/cipher.ts b/libs/common/src/vault/models/domain/cipher.ts index 475c9337525..79536f5379a 100644 --- a/libs/common/src/vault/models/domain/cipher.ts +++ b/libs/common/src/vault/models/domain/cipher.ts @@ -19,6 +19,7 @@ import { Identity } from "./identity"; import { Login } from "./login"; import { Password } from "./password"; import { SecureNote } from "./secure-note"; +import { SshKey } from "./ssh-key"; export class Cipher extends Domain implements Decryptable { readonly initializerKey = InitializerKey.Cipher; @@ -39,6 +40,7 @@ export class Cipher extends Domain implements Decryptable { identity: Identity; card: Card; secureNote: SecureNote; + sshKey: SshKey; attachments: Attachment[]; fields: Field[]; passwordHistory: Password[]; @@ -97,6 +99,9 @@ export class Cipher extends Domain implements Decryptable { case CipherType.Identity: this.identity = new Identity(obj.identity); break; + case CipherType.SshKey: + this.sshKey = new SshKey(obj.sshKey); + break; default: break; } @@ -156,6 +161,9 @@ export class Cipher extends Domain implements Decryptable { case CipherType.Identity: model.identity = await this.identity.decrypt(this.organizationId, encKey); break; + case CipherType.SshKey: + model.sshKey = await this.sshKey.decrypt(this.organizationId, encKey); + break; default: break; } @@ -240,6 +248,9 @@ export class Cipher extends Domain implements Decryptable { case CipherType.Identity: c.identity = this.identity.toIdentityData(); break; + case CipherType.SshKey: + c.sshKey = this.sshKey.toSshKeyData(); + break; default: break; } @@ -295,6 +306,9 @@ export class Cipher extends Domain implements Decryptable { case CipherType.SecureNote: domain.secureNote = SecureNote.fromJSON(obj.secureNote); break; + case CipherType.SshKey: + domain.sshKey = SshKey.fromJSON(obj.sshKey); + break; default: break; } diff --git a/libs/common/src/vault/models/domain/ssh-key.spec.ts b/libs/common/src/vault/models/domain/ssh-key.spec.ts new file mode 100644 index 00000000000..f56d738fde8 --- /dev/null +++ b/libs/common/src/vault/models/domain/ssh-key.spec.ts @@ -0,0 +1,67 @@ +import { mockEnc } from "../../../../spec"; +import { SshKeyApi } from "../api/ssh-key.api"; +import { SshKeyData } from "../data/ssh-key.data"; + +import { SshKey } from "./ssh-key"; + +describe("Sshkey", () => { + let data: SshKeyData; + + beforeEach(() => { + data = new SshKeyData( + new SshKeyApi({ + PrivateKey: "privateKey", + PublicKey: "publicKey", + KeyFingerprint: "keyFingerprint", + }), + ); + }); + + it("Convert", () => { + const sshKey = new SshKey(data); + + expect(sshKey).toEqual({ + privateKey: { encryptedString: "privateKey", encryptionType: 0 }, + publicKey: { encryptedString: "publicKey", encryptionType: 0 }, + keyFingerprint: { encryptedString: "keyFingerprint", encryptionType: 0 }, + }); + }); + + it("Convert from empty", () => { + const data = new SshKeyData(); + const sshKey = new SshKey(data); + + expect(sshKey).toEqual({ + privateKey: null, + publicKey: null, + keyFingerprint: null, + }); + }); + + it("toSshKeyData", () => { + const sshKey = new SshKey(data); + expect(sshKey.toSshKeyData()).toEqual(data); + }); + + it("Decrypt", async () => { + const sshKey = Object.assign(new SshKey(), { + privateKey: mockEnc("privateKey"), + publicKey: mockEnc("publicKey"), + keyFingerprint: mockEnc("keyFingerprint"), + }); + const expectedView = { + privateKey: "privateKey", + publicKey: "publicKey", + keyFingerprint: "keyFingerprint", + }; + + const loginView = await sshKey.decrypt(null); + expect(loginView).toEqual(expectedView); + }); + + describe("fromJSON", () => { + it("returns null if object is null", () => { + expect(SshKey.fromJSON(null)).toBeNull(); + }); + }); +}); diff --git a/libs/common/src/vault/models/domain/ssh-key.ts b/libs/common/src/vault/models/domain/ssh-key.ts new file mode 100644 index 00000000000..e7c24b45ba8 --- /dev/null +++ b/libs/common/src/vault/models/domain/ssh-key.ts @@ -0,0 +1,70 @@ +import { Jsonify } from "type-fest"; + +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; + +import Domain from "../../../platform/models/domain/domain-base"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +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; + + constructor(obj?: SshKeyData) { + super(); + if (obj == null) { + return; + } + + this.buildDomainModel( + this, + obj, + { + privateKey: null, + publicKey: null, + keyFingerprint: null, + }, + [], + ); + } + + decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise { + return this.decryptObj( + new SshKeyView(), + { + privateKey: null, + publicKey: null, + keyFingerprint: null, + }, + orgId, + encKey, + ); + } + + toSshKeyData(): SshKeyData { + const c = new SshKeyData(); + this.buildDataModel(this, c, { + privateKey: null, + publicKey: null, + keyFingerprint: null, + }); + return c; + } + + static fromJSON(obj: Partial>): SshKey { + if (obj == null) { + return null; + } + + 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, + }); + } +} diff --git a/libs/common/src/vault/models/request/cipher.request.ts b/libs/common/src/vault/models/request/cipher.request.ts index 52a55b6c3e4..f24254f7432 100644 --- a/libs/common/src/vault/models/request/cipher.request.ts +++ b/libs/common/src/vault/models/request/cipher.request.ts @@ -7,6 +7,7 @@ import { IdentityApi } from "../api/identity.api"; import { LoginUriApi } from "../api/login-uri.api"; import { LoginApi } from "../api/login.api"; import { SecureNoteApi } from "../api/secure-note.api"; +import { SshKeyApi } from "../api/ssh-key.api"; import { Cipher } from "../domain/cipher"; import { AttachmentRequest } from "./attachment.request"; @@ -23,6 +24,7 @@ export class CipherRequest { secureNote: SecureNoteApi; card: CardApi; identity: IdentityApi; + sshKey: SshKeyApi; fields: FieldApi[]; passwordHistory: PasswordHistoryRequest[]; // Deprecated, remove at some point and rename attachments2 to attachments @@ -93,6 +95,17 @@ export class CipherRequest { this.secureNote = new SecureNoteApi(); this.secureNote.type = cipher.secureNote.type; break; + case CipherType.SshKey: + this.sshKey = new SshKeyApi(); + this.sshKey.privateKey = + cipher.sshKey.privateKey != null ? cipher.sshKey.privateKey.encryptedString : null; + this.sshKey.publicKey = + cipher.sshKey.publicKey != null ? cipher.sshKey.publicKey.encryptedString : null; + this.sshKey.keyFingerprint = + cipher.sshKey.keyFingerprint != null + ? cipher.sshKey.keyFingerprint.encryptedString + : null; + break; case CipherType.Card: this.card = new CardApi(); this.card.cardholderName = diff --git a/libs/common/src/vault/models/response/cipher.response.ts b/libs/common/src/vault/models/response/cipher.response.ts index 67709b602e3..7e2805b7510 100644 --- a/libs/common/src/vault/models/response/cipher.response.ts +++ b/libs/common/src/vault/models/response/cipher.response.ts @@ -5,6 +5,7 @@ import { FieldApi } from "../api/field.api"; import { IdentityApi } from "../api/identity.api"; import { LoginApi } from "../api/login.api"; import { SecureNoteApi } from "../api/secure-note.api"; +import { SshKeyApi } from "../api/ssh-key.api"; import { AttachmentResponse } from "./attachment.response"; import { PasswordHistoryResponse } from "./password-history.response"; @@ -21,6 +22,7 @@ export class CipherResponse extends BaseResponse { card: CardApi; identity: IdentityApi; secureNote: SecureNoteApi; + sshKey: SshKeyApi; favorite: boolean; edit: boolean; viewPassword: boolean; @@ -75,6 +77,11 @@ export class CipherResponse extends BaseResponse { this.secureNote = new SecureNoteApi(secureNote); } + const sshKey = this.getResponseProperty("sshKey"); + if (sshKey != null) { + this.sshKey = new SshKeyApi(sshKey); + } + const fields = this.getResponseProperty("Fields"); if (fields != null) { this.fields = fields.map((f: any) => new FieldApi(f)); diff --git a/libs/common/src/vault/models/view/cipher.view.ts b/libs/common/src/vault/models/view/cipher.view.ts index 3ea3f109be1..4d429bb390f 100644 --- a/libs/common/src/vault/models/view/cipher.view.ts +++ b/libs/common/src/vault/models/view/cipher.view.ts @@ -14,6 +14,7 @@ import { IdentityView } from "./identity.view"; import { LoginView } from "./login.view"; import { PasswordHistoryView } from "./password-history.view"; import { SecureNoteView } from "./secure-note.view"; +import { SshKeyView } from "./ssh-key.view"; export class CipherView implements View, InitializerMetadata { readonly initializerKey = InitializerKey.CipherView; @@ -33,6 +34,7 @@ export class CipherView implements View, InitializerMetadata { identity = new IdentityView(); card = new CardView(); secureNote = new SecureNoteView(); + sshKey = new SshKeyView(); attachments: AttachmentView[] = null; fields: FieldView[] = null; passwordHistory: PasswordHistoryView[] = null; @@ -74,6 +76,8 @@ export class CipherView implements View, InitializerMetadata { return this.card; case CipherType.Identity: return this.identity; + case CipherType.SshKey: + return this.sshKey; default: break; } @@ -190,6 +194,9 @@ export class CipherView implements View, InitializerMetadata { case CipherType.SecureNote: view.secureNote = SecureNoteView.fromJSON(obj.secureNote); break; + case CipherType.SshKey: + view.sshKey = SshKeyView.fromJSON(obj.sshKey); + break; default: break; } diff --git a/libs/common/src/vault/models/view/ssh-key.view.ts b/libs/common/src/vault/models/view/ssh-key.view.ts new file mode 100644 index 00000000000..4fedb1f8a36 --- /dev/null +++ b/libs/common/src/vault/models/view/ssh-key.view.ts @@ -0,0 +1,41 @@ +import { Jsonify } from "type-fest"; + +import { SshKey } from "../domain/ssh-key"; + +import { ItemView } from "./item.view"; + +export class SshKeyView extends ItemView { + privateKey: string = null; + publicKey: string = null; + keyFingerprint: string = null; + + constructor(n?: SshKey) { + super(); + if (!n) { + return; + } + } + + get maskedPrivateKey(): string { + let lines = this.privateKey.split("\n").filter((l) => l.trim() !== ""); + lines = lines.map((l, i) => { + if (i === 0 || i === lines.length - 1) { + return l; + } + return this.maskLine(l); + }); + return lines.join("\n"); + } + + private maskLine(line: string): string { + return "•".repeat(32); + } + + get subTitle(): string { + return this.keyFingerprint; + } + + static fromJSON(obj: Partial>): SshKeyView { + return Object.assign(new SshKeyView(), obj); + } +} diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 154042601e9..474976932e8 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -54,6 +54,7 @@ import { LoginUri } from "../models/domain/login-uri"; import { Password } from "../models/domain/password"; import { SecureNote } from "../models/domain/secure-note"; import { SortedCiphersCache } from "../models/domain/sorted-ciphers-cache"; +import { SshKey } from "../models/domain/ssh-key"; import { CipherBulkDeleteRequest } from "../models/request/cipher-bulk-delete.request"; import { CipherBulkMoveRequest } from "../models/request/cipher-bulk-move.request"; import { CipherBulkRestoreRequest } from "../models/request/cipher-bulk-restore.request"; @@ -880,9 +881,11 @@ export class CipherService implements CipherServiceAbstraction { return new Cipher(updated[cipher.id as CipherId], cipher.localData); } - async saveCollectionsWithServerAdmin(cipher: Cipher): Promise { + async saveCollectionsWithServerAdmin(cipher: Cipher): Promise { const request = new CipherCollectionsRequest(cipher.collectionIds); - await this.apiService.putCipherCollectionsAdmin(cipher.id, request); + const response = await this.apiService.putCipherCollectionsAdmin(cipher.id, request); + const data = new CipherData(response); + return new Cipher(data); } /** @@ -1568,6 +1571,19 @@ export class CipherService implements CipherServiceAbstraction { key, ); return; + case CipherType.SshKey: + cipher.sshKey = new SshKey(); + await this.encryptObjProperty( + model.sshKey, + cipher.sshKey, + { + privateKey: null, + publicKey: null, + keyFingerprint: null, + }, + key, + ); + return; default: throw new Error("Unknown cipher type."); } diff --git a/libs/components/src/disclosure/disclosure-trigger-for.directive.ts b/libs/components/src/disclosure/disclosure-trigger-for.directive.ts new file mode 100644 index 00000000000..05470281729 --- /dev/null +++ b/libs/components/src/disclosure/disclosure-trigger-for.directive.ts @@ -0,0 +1,27 @@ +import { Directive, HostBinding, HostListener, Input } from "@angular/core"; + +import { DisclosureComponent } from "./disclosure.component"; + +@Directive({ + selector: "[bitDisclosureTriggerFor]", + exportAs: "disclosureTriggerFor", + standalone: true, +}) +export class DisclosureTriggerForDirective { + /** + * Accepts template reference for a bit-disclosure component instance + */ + @Input("bitDisclosureTriggerFor") disclosure: DisclosureComponent; + + @HostBinding("attr.aria-expanded") get ariaExpanded() { + return this.disclosure.open; + } + + @HostBinding("attr.aria-controls") get ariaControls() { + return this.disclosure.id; + } + + @HostListener("click") click() { + this.disclosure.open = !this.disclosure.open; + } +} diff --git a/libs/components/src/disclosure/disclosure.component.ts b/libs/components/src/disclosure/disclosure.component.ts new file mode 100644 index 00000000000..58c67ad0f0e --- /dev/null +++ b/libs/components/src/disclosure/disclosure.component.ts @@ -0,0 +1,21 @@ +import { Component, HostBinding, Input, booleanAttribute } from "@angular/core"; + +let nextId = 0; + +@Component({ + selector: "bit-disclosure", + standalone: true, + template: ``, +}) +export class DisclosureComponent { + /** + * Optionally init the disclosure in its opened state + */ + @Input({ transform: booleanAttribute }) open?: boolean = false; + + @HostBinding("class") get classList() { + return this.open ? "" : "tw-hidden"; + } + + @HostBinding("id") id = `bit-disclosure-${nextId++}`; +} diff --git a/libs/components/src/disclosure/disclosure.mdx b/libs/components/src/disclosure/disclosure.mdx new file mode 100644 index 00000000000..8df8e7025b8 --- /dev/null +++ b/libs/components/src/disclosure/disclosure.mdx @@ -0,0 +1,55 @@ +import { Meta, Story, Primary, Controls } from "@storybook/addon-docs"; + +import * as stories from "./disclosure.stories"; + + + +```ts +import { DisclosureComponent, DisclosureTriggerForDirective } from "@bitwarden/components"; +``` + +# Disclosure + +The `bit-disclosure` component is used in tandem with the `bitDisclosureTriggerFor` directive to +create an accessible content area whose visibility is controlled by a trigger button. + +To compose a disclosure and trigger: + +1. Create a trigger component (see "Supported Trigger Components" section below) +2. Create a `bit-disclosure` +3. Set a template reference on the `bit-disclosure` +4. Use the `bitDisclosureTriggerFor` directive on the trigger component, and pass it the + `bit-disclosure` template reference +5. Set the `open` property on the `bit-disclosure` to init the disclosure as either currently + expanded or currently collapsed. The disclosure will default to `false`, meaning it defaults to + being hidden. + +``` + +click button to hide this content +``` + + + + + + +## Supported Trigger Components + +This is the list of currently supported trigger components: + +- Icon button `muted` variant + +## Accessibility + +The disclosure and trigger directive functionality follow the +[Disclosure (Show/Hide)](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/) pattern for +accessibility, automatically handling the `aria-controls` and `aria-expanded` properties. A `button` +element must be used as the trigger for the disclosure. The `button` element must also have an +accessible label/title -- please follow the accessibility guidelines for whatever trigger component +you choose. diff --git a/libs/components/src/disclosure/disclosure.stories.ts b/libs/components/src/disclosure/disclosure.stories.ts new file mode 100644 index 00000000000..974589a667c --- /dev/null +++ b/libs/components/src/disclosure/disclosure.stories.ts @@ -0,0 +1,29 @@ +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; + +import { IconButtonModule } from "../icon-button"; + +import { DisclosureTriggerForDirective } from "./disclosure-trigger-for.directive"; +import { DisclosureComponent } from "./disclosure.component"; + +export default { + title: "Component Library/Disclosure", + component: DisclosureComponent, + decorators: [ + moduleMetadata({ + imports: [DisclosureTriggerForDirective, DisclosureComponent, IconButtonModule], + }), + ], +} as Meta; + +type Story = StoryObj; + +export const DisclosureWithIconButton: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + + click button to hide this content + `, + }), +}; diff --git a/libs/components/src/disclosure/index.ts b/libs/components/src/disclosure/index.ts new file mode 100644 index 00000000000..b5bdf68725f --- /dev/null +++ b/libs/components/src/disclosure/index.ts @@ -0,0 +1,2 @@ +export * from "./disclosure-trigger-for.directive"; +export * from "./disclosure.component"; diff --git a/libs/components/src/icon-button/icon-button.component.ts b/libs/components/src/icon-button/icon-button.component.ts index 54f6dfda963..d036e1c77ca 100644 --- a/libs/components/src/icon-button/icon-button.component.ts +++ b/libs/components/src/icon-button/icon-button.component.ts @@ -52,10 +52,14 @@ const styles: Record = { "tw-bg-transparent", "!tw-text-muted", "tw-border-transparent", + "aria-expanded:tw-bg-text-muted", + "aria-expanded:!tw-text-contrast", "hover:tw-bg-transparent-hover", "hover:tw-border-primary-700", "focus-visible:before:tw-ring-primary-700", "disabled:tw-opacity-60", + "aria-expanded:hover:tw-bg-secondary-700", + "aria-expanded:hover:tw-border-secondary-700", "disabled:hover:tw-border-transparent", "disabled:hover:tw-bg-transparent", ...focusRing, diff --git a/libs/components/src/icon-button/icon-button.mdx b/libs/components/src/icon-button/icon-button.mdx index 8361d4c3997..a45160d7884 100644 --- a/libs/components/src/icon-button/icon-button.mdx +++ b/libs/components/src/icon-button/icon-button.mdx @@ -29,8 +29,6 @@ Icon buttons can be found in other components such as: the [dialog](?path=/docs/component-library-dialogs--docs), and [table](?path=/docs/component-library-table--docs). - - ## Styles There are 4 common styles for button main, muted, contrast, and danger. The other styles follow the @@ -40,48 +38,48 @@ button component styles. Used for general icon buttons appearing on the theme’s main `background` - + ### Muted Used for low emphasis icon buttons appearing on the theme’s main `background` - + ### Contrast Used on a theme’s colored or contrasting backgrounds such as in the navigation or on toasts and banners. - + ### Danger Danger is used for “trash” actions throughout the experience, most commonly in the bottom right of the dialog component. - + ### Primary Used in place of the main button component if no text is used. This allows the button to display square. - + ### Secondary Used in place of the main button component if no text is used. This allows the button to display square. - + ### Light Used on a background that is dark in both light theme and dark theme. Example: end user navigation styles. - + **Note:** Main and contrast styles appear on backgrounds where using `primary-700` as a focus indicator does not meet WCAG graphic contrast guidelines. @@ -95,11 +93,11 @@ with less padding around the icon, such as in the navigation component. ### Small - + ### Default - + ## Accessibility diff --git a/libs/components/src/icon-button/icon-button.stories.ts b/libs/components/src/icon-button/icon-button.stories.ts index 0f25d2de583..b5542f78600 100644 --- a/libs/components/src/icon-button/icon-button.stories.ts +++ b/libs/components/src/icon-button/icon-button.stories.ts @@ -23,7 +23,7 @@ type Story = StoryObj; export const Default: Story = { render: (args) => ({ props: args, - template: ` + template: /*html*/ ` Button Button @@ -56,7 +56,7 @@ export const Small: Story = { export const Primary: Story = { render: (args) => ({ props: args, - template: ` + template: /*html*/ ` Button `, }), @@ -96,7 +96,7 @@ export const Muted: Story = { export const Light: Story = { render: (args) => ({ props: args, - template: ` + template: /*html*/ ` Button @@ -110,7 +110,7 @@ export const Light: Story = { export const Contrast: Story = { render: (args) => ({ props: args, - template: ` + template: /*html*/ ` Button diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index 6881d801e0f..810f32bdd3c 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -13,6 +13,7 @@ export * from "./chip-select"; export * from "./color-password"; export * from "./container"; export * from "./dialog"; +export * from "./disclosure"; export * from "./form-field"; export * from "./icon-button"; export * from "./icon"; diff --git a/libs/components/src/table/sortable.component.ts b/libs/components/src/table/sortable.component.ts index b2e456953b1..c6d60f155b2 100644 --- a/libs/components/src/table/sortable.component.ts +++ b/libs/components/src/table/sortable.component.ts @@ -1,7 +1,7 @@ import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { Component, HostBinding, Input, OnInit } from "@angular/core"; -import type { SortFn } from "./table-data-source"; +import type { SortDirection, SortFn } from "./table-data-source"; import { TableComponent } from "./table.component"; @Component({ @@ -19,12 +19,16 @@ export class SortableComponent implements OnInit { */ @Input() bitSortable: string; - private _default: boolean; + private _default: SortDirection | boolean = false; /** * Mark the column as the default sort column */ - @Input() set default(value: boolean | "") { - this._default = coerceBooleanProperty(value); + @Input() set default(value: SortDirection | boolean | "") { + if (value === "desc" || value === "asc") { + this._default = value; + } else { + this._default = coerceBooleanProperty(value) ? "asc" : false; + } } /** @@ -32,6 +36,11 @@ export class SortableComponent implements OnInit { * * @example * fn = (a, b) => a.name.localeCompare(b.name) + * + * fn = (a, b, direction) => { + * const result = a.name.localeCompare(b.name) + * return direction === 'asc' ? result : -result; + * } */ @Input() fn: SortFn; @@ -52,8 +61,18 @@ export class SortableComponent implements OnInit { protected setActive() { if (this.table.dataSource) { - const direction = this.isActive && this.direction === "asc" ? "desc" : "asc"; - this.table.dataSource.sort = { column: this.bitSortable, direction: direction, fn: this.fn }; + const defaultDirection = this._default === "desc" ? "desc" : "asc"; + const direction = this.isActive + ? this.direction === "asc" + ? "desc" + : "asc" + : defaultDirection; + + this.table.dataSource.sort = { + column: this.bitSortable, + direction: direction, + fn: this.fn, + }; } } diff --git a/libs/components/src/table/table-data-source.ts b/libs/components/src/table/table-data-source.ts index 6501c9bffbd..8a5d994dc18 100644 --- a/libs/components/src/table/table-data-source.ts +++ b/libs/components/src/table/table-data-source.ts @@ -3,7 +3,7 @@ import { DataSource } from "@angular/cdk/collections"; import { BehaviorSubject, combineLatest, map, Observable, Subscription } from "rxjs"; export type SortDirection = "asc" | "desc"; -export type SortFn = (a: any, b: any) => number; +export type SortFn = (a: any, b: any, direction?: SortDirection) => number; export type Sort = { column?: string; direction: SortDirection; @@ -166,7 +166,7 @@ export class TableDataSource extends DataSource { return data.sort((a, b) => { // If a custom sort function is provided, use it instead of the default. if (sort.fn) { - return sort.fn(a, b) * directionModifier; + return sort.fn(a, b, sort.direction) * directionModifier; } let valueA = this.sortingDataAccessor(a, column); diff --git a/libs/components/src/table/table.mdx b/libs/components/src/table/table.mdx index 3f28dd93b68..8d784190ed9 100644 --- a/libs/components/src/table/table.mdx +++ b/libs/components/src/table/table.mdx @@ -105,7 +105,7 @@ within the `ng-template`which provides access to the rows using `let-rows$`. We provide a simple component for displaying sortable column headers. The `bitSortable` component wires up to the `TableDataSource` and will automatically sort the data when clicked and display an -indicator for which column is currently sorted. The dafault sorting can be specified by setting the +indicator for which column is currently sorted. The default sorting can be specified by setting the `default`. ```html @@ -113,10 +113,23 @@ indicator for which column is currently sorted. The dafault sorting can be speci Name ``` +For default sorting in descending order, set default="desc" + +```html +Name +``` + It's also possible to define a custom sorting function by setting the `fn` input. ```ts +// Basic sort function const sortFn = (a: T, b: T) => (a.id > b.id ? 1 : -1); + +// Direction aware sort function +const sortByName = (a: T, b: T, direction?: SortDirection) => { + const result = a.name.localeCompare(b.name); + return direction === "asc" ? result : -result; +}; ``` ### Filtering diff --git a/libs/importer/src/components/dialog/import-success-dialog.component.ts b/libs/importer/src/components/dialog/import-success-dialog.component.ts index 1e0d4f7fda9..75e0754423e 100644 --- a/libs/importer/src/components/dialog/import-success-dialog.component.ts +++ b/libs/importer/src/components/dialog/import-success-dialog.component.ts @@ -38,6 +38,7 @@ export class ImportSuccessDialogComponent implements OnInit { let cards = 0; let identities = 0; let secureNotes = 0; + let sshKeys = 0; this.data.ciphers.map((c) => { switch (c.type) { case CipherType.Login: @@ -52,6 +53,9 @@ export class ImportSuccessDialogComponent implements OnInit { case CipherType.Identity: identities++; break; + case CipherType.SshKey: + sshKeys++; + break; default: break; } @@ -70,6 +74,9 @@ export class ImportSuccessDialogComponent implements OnInit { if (secureNotes > 0) { list.push({ icon: "sticky-note", type: "typeSecureNote", count: secureNotes }); } + if (sshKeys > 0) { + list.push({ icon: "key", type: "typeSSHKey", count: sshKeys }); + } if (this.data.folders.length > 0) { list.push({ icon: "folder", type: "folders", count: this.data.folders.length }); } diff --git a/libs/tools/generator/components/src/catchall-settings.component.ts b/libs/tools/generator/components/src/catchall-settings.component.ts index 74fb37d2335..3a42d682971 100644 --- a/libs/tools/generator/components/src/catchall-settings.component.ts +++ b/libs/tools/generator/components/src/catchall-settings.component.ts @@ -12,11 +12,6 @@ import { import { completeOnAccountSwitch } from "./util"; -/** Splits an email into a username, subaddress, and domain named group. - * Subaddress is optional. - */ -export const DOMAIN_PARSER = new RegExp("[^@]+@(?.+)"); - /** Options group for catchall emails */ @Component({ selector: "tools-catchall-settings", diff --git a/libs/tools/generator/components/src/credential-generator.component.html b/libs/tools/generator/components/src/credential-generator.component.html index 0182bd1c204..ce86abe80ae 100644 --- a/libs/tools/generator/components/src/credential-generator.component.html +++ b/libs/tools/generator/components/src/credential-generator.component.html @@ -39,14 +39,12 @@ [value, value > 0] as const), - tap(([value]) => (lastMinNumber = this.numbers.value ? value : lastMinNumber)), + tap(([value, checkNumbers]) => (lastMinNumber = checkNumbers ? value : lastMinNumber)), takeUntil(this.destroyed$), ) - .subscribe(([, checked]) => this.numbers.setValue(checked, { emitEvent: false })); + .subscribe(([, checkNumbers]) => this.numbers.setValue(checkNumbers, { emitEvent: false })); let lastMinSpecial = 1; this.special.valueChanges @@ -188,10 +188,10 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy { this.minSpecial.valueChanges .pipe( map((value) => [value, value > 0] as const), - tap(([value]) => (lastMinSpecial = this.special.value ? value : lastMinSpecial)), + tap(([value, checkSpecial]) => (lastMinSpecial = checkSpecial ? value : lastMinSpecial)), takeUntil(this.destroyed$), ) - .subscribe(([, checked]) => this.special.setValue(checked, { emitEvent: false })); + .subscribe(([, checkSpecial]) => this.special.setValue(checkSpecial, { emitEvent: false })); // `onUpdated` depends on `settings` because the UserStateSubject is asynchronous; // subscribing directly to `this.settings.valueChanges` introduces a race condition. diff --git a/libs/tools/generator/components/src/username-generator.component.html b/libs/tools/generator/components/src/username-generator.component.html index f96374e063b..31e224713ed 100644 --- a/libs/tools/generator/components/src/username-generator.component.html +++ b/libs/tools/generator/components/src/username-generator.component.html @@ -59,25 +59,21 @@ = { min: 0, max: 0 }; const AtLeastOne: Constraint = { min: 1 }; const RequiresTrue: Constraint = { requiredValue: true }; @@ -159,6 +160,7 @@ export { enforceConstant, readonlyTrueWhen, fitLength, + Zero, AtLeastOne, RequiresTrue, }; diff --git a/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.spec.ts b/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.spec.ts index 96f590f8ed6..d05d75ffb76 100644 --- a/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.spec.ts +++ b/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.spec.ts @@ -1,6 +1,6 @@ import { DefaultPasswordBoundaries, DefaultPasswordGenerationOptions, Policies } from "../data"; -import { AtLeastOne } from "./constraints"; +import { AtLeastOne, Zero } from "./constraints"; import { DynamicPasswordPolicyConstraints } from "./dynamic-password-policy-constraints"; describe("DynamicPasswordPolicyConstraints", () => { @@ -207,7 +207,7 @@ describe("DynamicPasswordPolicyConstraints", () => { expect(calibrated.constraints.minNumber).toEqual(dynamic.constraints.minNumber); }); - it("disables the minNumber constraint when the state's number flag is false", () => { + it("outputs the zero constraint when the state's number flag is false", () => { const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue); const state = { ...DefaultPasswordGenerationOptions, @@ -216,7 +216,7 @@ describe("DynamicPasswordPolicyConstraints", () => { const calibrated = dynamic.calibrate(state); - expect(calibrated.constraints.minNumber).toBeUndefined(); + expect(calibrated.constraints.minNumber).toEqual(Zero); }); it("outputs the minSpecial constraint when the state's special flag is true", () => { @@ -231,7 +231,7 @@ describe("DynamicPasswordPolicyConstraints", () => { expect(calibrated.constraints.minSpecial).toEqual(dynamic.constraints.minSpecial); }); - it("disables the minSpecial constraint when the state's special flag is false", () => { + it("outputs the zero constraint when the state's special flag is false", () => { const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue); const state = { ...DefaultPasswordGenerationOptions, @@ -240,23 +240,7 @@ describe("DynamicPasswordPolicyConstraints", () => { const calibrated = dynamic.calibrate(state); - expect(calibrated.constraints.minSpecial).toBeUndefined(); - }); - - it("copies the minimum length constraint", () => { - const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue); - - const calibrated = dynamic.calibrate(DefaultPasswordGenerationOptions); - - expect(calibrated.constraints.minSpecial).toBeUndefined(); - }); - - it("overrides the minimum length constraint when it is less than the sum of the state's minimums", () => { - const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue); - - const calibrated = dynamic.calibrate(DefaultPasswordGenerationOptions); - - expect(calibrated.constraints.minSpecial).toBeUndefined(); + expect(calibrated.constraints.minSpecial).toEqual(Zero); }); }); }); diff --git a/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.ts b/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.ts index daff9882547..7fe76061885 100644 --- a/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.ts +++ b/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.ts @@ -7,7 +7,7 @@ import { import { DefaultPasswordBoundaries } from "../data"; import { PasswordGeneratorPolicy, PasswordGeneratorSettings } from "../types"; -import { atLeast, atLeastSum, maybe, readonlyTrueWhen, AtLeastOne } from "./constraints"; +import { atLeast, atLeastSum, maybe, readonlyTrueWhen, AtLeastOne, Zero } from "./constraints"; import { PasswordPolicyConstraints } from "./password-policy-constraints"; /** Creates state constraints by blending policy and password settings. */ @@ -68,8 +68,8 @@ export class DynamicPasswordPolicyConstraints ...this.constraints, minLowercase: maybe(lowercase, this.constraints.minLowercase ?? AtLeastOne), minUppercase: maybe(uppercase, this.constraints.minUppercase ?? AtLeastOne), - minNumber: maybe(number, this.constraints.minNumber), - minSpecial: maybe(special, this.constraints.minSpecial), + minNumber: maybe(number, this.constraints.minNumber) ?? Zero, + minSpecial: maybe(special, this.constraints.minSpecial) ?? Zero, }; // lower bound of length must always at least fit its sub-lengths diff --git a/libs/tools/generator/core/src/services/credential-generator.service.spec.ts b/libs/tools/generator/core/src/services/credential-generator.service.spec.ts index bd26642157e..b6b43073431 100644 --- a/libs/tools/generator/core/src/services/credential-generator.service.spec.ts +++ b/libs/tools/generator/core/src/services/credential-generator.service.spec.ts @@ -1163,7 +1163,11 @@ describe("CredentialGeneratorService", () => { await awaitAsync(); const result = await firstValueFrom(stateProvider.getUserState$(SettingsKey, SomeUser)); - expect(result).toEqual({ foo: "next value" }); + expect(result).toEqual({ + foo: "next value", + // FIXME: don't leak this detail into the test + "$^$ALWAYS_UPDATE_KLUDGE_PROPERTY$^$": 0, + }); }); it("waits for the user to become available", async () => { diff --git a/libs/vault/src/cipher-form/cipher-form-container.ts b/libs/vault/src/cipher-form/cipher-form-container.ts index 87495df643f..8010cf260df 100644 --- a/libs/vault/src/cipher-form/cipher-form-container.ts +++ b/libs/vault/src/cipher-form/cipher-form-container.ts @@ -8,6 +8,7 @@ import { CustomFieldsComponent } from "./components/custom-fields/custom-fields. import { IdentitySectionComponent } from "./components/identity/identity.component"; import { ItemDetailsSectionComponent } from "./components/item-details/item-details-section.component"; import { LoginDetailsSectionComponent } from "./components/login-details-section/login-details-section.component"; +import { SshKeySectionComponent } from "./components/sshkey-section/sshkey-section.component"; /** * The complete form for a cipher. Includes all the sub-forms from their respective section components. @@ -20,6 +21,7 @@ export type CipherForm = { autoFillOptions?: AutofillOptionsComponent["autofillOptionsForm"]; cardDetails?: CardDetailsSectionComponent["cardDetailsForm"]; identityDetails?: IdentitySectionComponent["identityForm"]; + sshKeyDetails?: SshKeySectionComponent["sshKeyForm"]; customFields?: CustomFieldsComponent["customFieldsForm"]; }; diff --git a/libs/vault/src/cipher-form/components/cipher-form.component.html b/libs/vault/src/cipher-form/components/cipher-form.component.html index 60dbd91fc36..2644741385b 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.html +++ b/libs/vault/src/cipher-form/components/cipher-form.component.html @@ -22,6 +22,12 @@ [disabled]="config.mode === 'partial-edit'" > + + 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 4df6aa67ea6..d1bbbef0910 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.ts +++ b/libs/vault/src/cipher-form/components/cipher-form.component.ts @@ -42,6 +42,7 @@ import { CardDetailsSectionComponent } from "./card-details-section/card-details import { IdentitySectionComponent } from "./identity/identity.component"; import { ItemDetailsSectionComponent } from "./item-details/item-details-section.component"; import { LoginDetailsSectionComponent } from "./login-details-section/login-details-section.component"; +import { SshKeySectionComponent } from "./sshkey-section/sshkey-section.component"; @Component({ selector: "vault-cipher-form", @@ -65,6 +66,7 @@ import { LoginDetailsSectionComponent } from "./login-details-section/login-deta ItemDetailsSectionComponent, CardDetailsSectionComponent, IdentitySectionComponent, + SshKeySectionComponent, NgIf, AdditionalOptionsSectionComponent, LoginDetailsSectionComponent, diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts index b62557a4329..93229bda6c3 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts @@ -87,7 +87,12 @@ describe("ItemDetailsSectionComponent", () => { component.config.allowPersonalOwnership = true; component.config.organizations = [{ id: "org1" } as Organization]; component.config.collections = [ - { id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView, + { + id: "col1", + name: "Collection 1", + organizationId: "org1", + canEditItems: (_org) => true, + } as CollectionView, ]; component.originalCipherView = { name: "cipher1", @@ -116,8 +121,18 @@ describe("ItemDetailsSectionComponent", () => { component.config.allowPersonalOwnership = true; component.config.organizations = [{ id: "org1" } as Organization]; component.config.collections = [ - { id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView, - { id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView, + { + id: "col1", + name: "Collection 1", + organizationId: "org1", + canEditItems: (_org) => false, + } as CollectionView, + { + id: "col2", + name: "Collection 2", + organizationId: "org1", + canEditItems: (_org) => true, + } as CollectionView, ]; component.originalCipherView = { name: "cipher1", @@ -367,9 +382,24 @@ describe("ItemDetailsSectionComponent", () => { } as CipherView; component.config.organizations = [{ id: "org1" } as Organization]; component.config.collections = [ - { id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView, - { id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView, - { id: "col3", name: "Collection 3", organizationId: "org1" } as CollectionView, + { + id: "col1", + name: "Collection 1", + organizationId: "org1", + canEditItems: (_org) => true, + } as CollectionView, + { + id: "col2", + name: "Collection 2", + organizationId: "org1", + canEditItems: (_org) => true, + } as CollectionView, + { + id: "col3", + name: "Collection 3", + organizationId: "org1", + canEditItems: (_org) => true, + } as CollectionView, ]; fixture.detectChanges(); @@ -387,7 +417,12 @@ describe("ItemDetailsSectionComponent", () => { component.config.allowPersonalOwnership = true; component.config.organizations = [{ id: "org1" } as Organization]; component.config.collections = [ - { id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView, + { + id: "col1", + name: "Collection 1", + organizationId: "org1", + canEditItems: (_org) => true, + } as CollectionView, ]; fixture.detectChanges(); @@ -414,13 +449,24 @@ describe("ItemDetailsSectionComponent", () => { } as CipherView; component.config.organizations = [{ id: "org1" } as Organization]; component.config.collections = [ - { id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView, - { id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView, + { + id: "col1", + name: "Collection 1", + organizationId: "org1", + canEditItems: (_org) => true, + } as CollectionView, + { + id: "col2", + name: "Collection 2", + organizationId: "org1", + canEditItems: (_org) => true, + } as CollectionView, { id: "col3", name: "Collection 3", organizationId: "org1", readOnly: true, + canEditItems: (_org) => true, } as CollectionView, ]; @@ -433,5 +479,94 @@ describe("ItemDetailsSectionComponent", () => { expect(collectionHint).not.toBeNull(); }); + + it("should allow all collections to be altered when `config.admin` is true", async () => { + component.config.admin = true; + component.config.allowPersonalOwnership = true; + component.config.organizations = [{ id: "org1" } as Organization]; + component.config.collections = [ + { + id: "col1", + name: "Collection 1", + organizationId: "org1", + readOnly: true, + canEditItems: (_org) => false, + } as CollectionView, + { + id: "col2", + name: "Collection 2", + organizationId: "org1", + readOnly: true, + canEditItems: (_org) => false, + } as CollectionView, + { + id: "col3", + name: "Collection 3", + organizationId: "org1", + readOnly: false, + canEditItems: (_org) => false, + } as CollectionView, + ]; + + fixture.detectChanges(); + await fixture.whenStable(); + + component.itemDetailsForm.controls.organizationId.setValue("org1"); + + expect(component["collectionOptions"].map((c) => c.id)).toEqual(["col1", "col2", "col3"]); + }); + }); + + describe("readonlyCollections", () => { + beforeEach(() => { + component.config.mode = "edit"; + component.config.admin = true; + component.config.collections = [ + { + id: "col1", + name: "Collection 1", + organizationId: "org1", + readOnly: true, + canEditItems: (_org) => false, + } as CollectionView, + { + id: "col2", + name: "Collection 2", + organizationId: "org1", + canEditItems: (_org) => false, + } as CollectionView, + { + id: "col3", + name: "Collection 3", + organizationId: "org1", + readOnly: true, + canEditItems: (_org) => false, + } as CollectionView, + ]; + component.originalCipherView = { + name: "cipher1", + organizationId: "org1", + folderId: "folder1", + collectionIds: ["col1", "col2", "col3"], + favorite: true, + } as CipherView; + component.config.organizations = [{ id: "org1" } as Organization]; + }); + + it("should not show collections as readonly when `config.admin` is true", async () => { + await component.ngOnInit(); + fixture.detectChanges(); + + // Filters out all collections + expect(component["readOnlyCollections"]).toEqual([]); + + // Non-admin, keep readonly collections + component.config.admin = false; + + await component.ngOnInit(); + fixture.detectChanges(); + + expect(component["readOnlyCollections"]).toEqual(["Collection 1", "Collection 3"]); + }); }); }); 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 86a8818bbe3..ea82aa0cae4 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 @@ -240,7 +240,11 @@ export class ItemDetailsSectionComponent implements OnInit { } else if (this.config.mode === "edit") { this.readOnlyCollections = this.collections .filter( - (c) => c.readOnly && this.originalCipherView.collectionIds.includes(c.id as CollectionId), + // When the configuration is set up for admins, they can alter read only collections + (c) => + c.readOnly && + !this.config.admin && + this.originalCipherView.collectionIds.includes(c.id as CollectionId), ) .map((c) => c.name); } @@ -262,12 +266,24 @@ export class ItemDetailsSectionComponent implements OnInit { collectionsControl.disable(); this.showCollectionsControl = false; return; + } else { + collectionsControl.enable(); + this.showCollectionsControl = true; } + const organization = this.organizations.find((o) => o.id === orgId); + this.collectionOptions = this.collections .filter((c) => { - // If partial edit mode, show all org collections because the control is disabled. - return c.organizationId === orgId && (this.partialEdit || !c.readOnly); + // Filter criteria: + // - The collection belongs to the organization + // - When in partial edit mode, show all org collections because the control is disabled. + // - The user can edit items within the collection + // - When viewing as an admin, all collections should be shown, even readonly. When non-admin, filter out readonly collections + return ( + c.organizationId === orgId && + (this.partialEdit || c.canEditItems(organization) || this.config.admin) + ); }) .map((c) => ({ id: c.id, diff --git a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.html b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.html new file mode 100644 index 00000000000..51b07a1cbf3 --- /dev/null +++ b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.html @@ -0,0 +1,30 @@ + + + + {{ "typeSshKey" | i18n }} + + + + + {{ "sshPrivateKey" | i18n }} + + + + + + {{ "sshPublicKey" | i18n }} + + + + + {{ "sshFingerprint" | i18n }} + + + + 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 new file mode 100644 index 00000000000..a15237421bd --- /dev/null +++ b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts @@ -0,0 +1,80 @@ +import { CommonModule } from "@angular/common"; +import { Component, Input, OnInit } from "@angular/core"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + CardComponent, + FormFieldModule, + IconButtonModule, + SectionComponent, + SectionHeaderComponent, + SelectModule, + TypographyModule, +} from "@bitwarden/components"; + +import { CipherFormContainer } from "../../cipher-form-container"; + +@Component({ + selector: "vault-sshkey-section", + templateUrl: "./sshkey-section.component.html", + standalone: true, + imports: [ + CardComponent, + SectionComponent, + TypographyModule, + FormFieldModule, + ReactiveFormsModule, + SelectModule, + SectionHeaderComponent, + IconButtonModule, + JslibModule, + CommonModule, + ], +}) +export class SshKeySectionComponent implements OnInit { + /** The original cipher */ + @Input() originalCipherView: CipherView; + + /** True when all fields should be disabled */ + @Input() disabled: boolean; + + /** + * All form fields associated with the ssh key + * + * Note: `as` is used to assert the type of the form control, + * leaving as just null gets inferred as `unknown` + */ + sshKeyForm = this.formBuilder.group({ + privateKey: null as string | null, + publicKey: null as string | null, + keyFingerprint: null as string | null, + }); + + constructor( + private cipherFormContainer: CipherFormContainer, + private formBuilder: FormBuilder, + private i18nService: I18nService, + ) {} + + ngOnInit() { + if (this.originalCipherView?.card) { + this.setInitialValues(); + } + + this.sshKeyForm.disable(); + } + + /** Set form initial form values from the current cipher */ + private setInitialValues() { + const { privateKey, publicKey, keyFingerprint } = this.originalCipherView.sshKey; + + this.sshKeyForm.setValue({ + privateKey, + publicKey, + keyFingerprint, + }); + } +} diff --git a/libs/vault/src/cipher-form/services/default-cipher-form.service.ts b/libs/vault/src/cipher-form/services/default-cipher-form.service.ts index 8e73d9edd40..1b7e86f82a7 100644 --- a/libs/vault/src/cipher-form/services/default-cipher-form.service.ts +++ b/libs/vault/src/cipher-form/services/default-cipher-form.service.ts @@ -1,6 +1,7 @@ import { inject, Injectable } from "@angular/core"; import { firstValueFrom, map } from "rxjs"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; @@ -17,6 +18,7 @@ function isSetEqual(a: Set, b: Set) { export class DefaultCipherFormService implements CipherFormService { private cipherService: CipherService = inject(CipherService); private accountService: AccountService = inject(AccountService); + private apiService: ApiService = inject(ApiService); async decryptCipher(cipher: Cipher): Promise { const activeUserId = await firstValueFrom( @@ -66,11 +68,21 @@ export class DefaultCipherFormService implements CipherFormService { // Updating a cipher with collection changes is not supported with a single request currently // First update the cipher with the original collectionIds encryptedCipher.collectionIds = config.originalCipher.collectionIds; - await this.cipherService.updateWithServer(encryptedCipher, config.admin); + await this.cipherService.updateWithServer( + encryptedCipher, + config.admin || originalCollectionIds.size === 0, + config.mode !== "clone", + ); // Then save the new collection changes separately encryptedCipher.collectionIds = cipher.collectionIds; - savedCipher = await this.cipherService.saveCollectionsWithServer(encryptedCipher); + + if (config.admin || originalCollectionIds.size === 0) { + // When using an admin config or the cipher was unassigned, update collections as an admin + savedCipher = await this.cipherService.saveCollectionsWithServerAdmin(encryptedCipher); + } else { + savedCipher = await this.cipherService.saveCollectionsWithServer(encryptedCipher); + } } // Its possible the cipher was made no longer available due to collection assignment changes diff --git a/libs/vault/src/cipher-view/cipher-view.component.html b/libs/vault/src/cipher-view/cipher-view.component.html index ad5191b0e2b..f0ebeecdf40 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.html +++ b/libs/vault/src/cipher-view/cipher-view.component.html @@ -40,6 +40,9 @@ + + + diff --git a/libs/vault/src/cipher-view/cipher-view.component.ts b/libs/vault/src/cipher-view/cipher-view.component.ts index 5d61caf52f3..597be3283e6 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.ts +++ b/libs/vault/src/cipher-view/cipher-view.component.ts @@ -21,6 +21,7 @@ import { CustomFieldV2Component } from "./custom-fields/custom-fields-v2.compone import { ItemDetailsV2Component } from "./item-details/item-details-v2.component"; import { ItemHistoryV2Component } from "./item-history/item-history-v2.component"; import { LoginCredentialsViewComponent } from "./login-credentials/login-credentials-view.component"; +import { SshKeyViewComponent } from "./sshkey-sections/sshkey-view.component"; import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-identity-sections.component"; @Component({ @@ -38,6 +39,7 @@ import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-ide ItemHistoryV2Component, CustomFieldV2Component, CardDetailsComponent, + SshKeyViewComponent, ViewIdentitySectionsComponent, LoginCredentialsViewComponent, AutofillOptionsViewComponent, @@ -95,9 +97,14 @@ export class CipherViewComponent implements OnChanges, OnDestroy { return this.cipher.login?.uris.length > 0; } + get hasSshKey() { + return this.cipher.sshKey?.privateKey; + } + async loadCipherData() { // Load collections if not provided and the cipher has collectionIds if ( + this.cipher.collectionIds && this.cipher.collectionIds.length > 0 && (!this.collections || this.collections.length === 0) ) { diff --git a/libs/vault/src/cipher-view/sshkey-sections/sshkey-view.component.html b/libs/vault/src/cipher-view/sshkey-sections/sshkey-view.component.html new file mode 100644 index 00000000000..ee5a94249c4 --- /dev/null +++ b/libs/vault/src/cipher-view/sshkey-sections/sshkey-view.component.html @@ -0,0 +1,50 @@ + + + {{ "typeSshKey" | i18n }} + + + + {{ "sshPrivateKey" | i18n }} + + + + + + {{ "sshPublicKey" | i18n }} + + + + + {{ "sshFingerprint" | i18n }} + + + + + 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 new file mode 100644 index 00000000000..7f553dbe58b --- /dev/null +++ b/libs/vault/src/cipher-view/sshkey-sections/sshkey-view.component.ts @@ -0,0 +1,35 @@ +import { CommonModule } from "@angular/common"; +import { Component, Input } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view"; +import { + CardComponent, + SectionComponent, + SectionHeaderComponent, + TypographyModule, + FormFieldModule, + IconButtonModule, +} from "@bitwarden/components"; + +import { OrgIconDirective } from "../../components/org-icon.directive"; + +@Component({ + selector: "app-sshkey-view", + templateUrl: "sshkey-view.component.html", + standalone: true, + imports: [ + CommonModule, + JslibModule, + CardComponent, + SectionComponent, + SectionHeaderComponent, + TypographyModule, + OrgIconDirective, + FormFieldModule, + IconButtonModule, + ], +}) +export class SshKeyViewComponent { + @Input() sshKey: SshKeyView; +} diff --git a/libs/vault/src/components/copy-cipher-field.directive.ts b/libs/vault/src/components/copy-cipher-field.directive.ts index 7d842c36bfe..e03419815bf 100644 --- a/libs/vault/src/components/copy-cipher-field.directive.ts +++ b/libs/vault/src/components/copy-cipher-field.directive.ts @@ -91,6 +91,12 @@ export class CopyCipherFieldDirective implements OnChanges { return this.cipher.identity?.fullAddressForCopy; case "secureNote": return this.cipher.notes; + case "privateKey": + return this.cipher.sshKey?.privateKey; + case "publicKey": + return this.cipher.sshKey?.publicKey; + case "keyFingerprint": + return this.cipher.sshKey?.keyFingerprint; default: return null; } diff --git a/libs/vault/src/components/password-history-view/password-history-view.component.html b/libs/vault/src/components/password-history-view/password-history-view.component.html index 44b7fea5f75..459c679945c 100644 --- a/libs/vault/src/components/password-history-view/password-history-view.component.html +++ b/libs/vault/src/components/password-history-view/password-history-view.component.html @@ -15,10 +15,10 @@ bitIconButton="bwi-clone" [appA11yTitle]="'copyPassword' | i18n" appStopClick - (click)="copy(h.password)" - > - - + [appCopyClick]="h.password" + [valueLabel]="'password' | i18n" + showToast + > diff --git a/libs/vault/src/components/password-history-view/password-history-view.component.spec.ts b/libs/vault/src/components/password-history-view/password-history-view.component.spec.ts index 8772a245821..3900681f230 100644 --- a/libs/vault/src/components/password-history-view/password-history-view.component.spec.ts +++ b/libs/vault/src/components/password-history-view/password-history-view.component.spec.ts @@ -3,14 +3,13 @@ import { By } from "@angular/platform-browser"; import { BehaviorSubject } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { ColorPasswordModule, ItemModule, ToastService } from "@bitwarden/components"; +import { ColorPasswordModule, ItemModule } from "@bitwarden/components"; import { ColorPasswordComponent } from "@bitwarden/components/src/color-password/color-password.component"; import { PasswordHistoryViewComponent } from "./password-history-view.component"; @@ -25,8 +24,6 @@ describe("PasswordHistoryViewComponent", () => { organizationId: "222-444-555", } as CipherView; - const copyToClipboard = jest.fn(); - const showToast = jest.fn(); const activeAccount$ = new BehaviorSubject<{ id: string }>({ id: "666-444-444" }); const mockCipherService = { get: jest.fn().mockResolvedValue({ decrypt: jest.fn().mockResolvedValue(mockCipher) }), @@ -36,17 +33,13 @@ describe("PasswordHistoryViewComponent", () => { beforeEach(async () => { mockCipherService.get.mockClear(); mockCipherService.getKeyForCipherKeyDecryption.mockClear(); - copyToClipboard.mockClear(); - showToast.mockClear(); await TestBed.configureTestingModule({ imports: [ItemModule, ColorPasswordModule, JslibModule], providers: [ - { provide: WINDOW, useValue: window }, { provide: CipherService, useValue: mockCipherService }, - { provide: PlatformUtilsService, useValue: { copyToClipboard } }, + { provide: PlatformUtilsService }, { provide: AccountService, useValue: { activeAccount$ } }, - { provide: ToastService, useValue: { showToast } }, { provide: I18nService, useValue: { t: (key: string) => key } }, ], }).compileComponents(); @@ -80,18 +73,5 @@ describe("PasswordHistoryViewComponent", () => { "bad-password-2", ]); }); - - it("copies a password", () => { - const copyButton = fixture.debugElement.query(By.css("button")); - - copyButton.nativeElement.click(); - - expect(copyToClipboard).toHaveBeenCalledWith("bad-password-1", { window: window }); - expect(showToast).toHaveBeenCalledWith({ - message: "passwordCopied", - title: "", - variant: "info", - }); - }); }); }); 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 5e858af7275..a0f0aa6b35b 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 @@ -1,21 +1,14 @@ import { CommonModule } from "@angular/common"; -import { OnInit, Inject, Component, Input } from "@angular/core"; +import { OnInit, Component, Input } from "@angular/core"; import { firstValueFrom, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { PasswordHistoryView } from "@bitwarden/common/vault/models/view/password-history.view"; -import { - ToastService, - ItemModule, - ColorPasswordModule, - IconButtonModule, -} from "@bitwarden/components"; +import { ItemModule, ColorPasswordModule, IconButtonModule } from "@bitwarden/components"; @Component({ selector: "vault-password-history-view", @@ -33,29 +26,15 @@ export class PasswordHistoryViewComponent implements OnInit { history: PasswordHistoryView[] = []; constructor( - @Inject(WINDOW) private win: Window, protected cipherService: CipherService, - protected platformUtilsService: PlatformUtilsService, protected i18nService: I18nService, protected accountService: AccountService, - protected toastService: ToastService, ) {} async ngOnInit() { await this.init(); } - /** Copies a password to the clipboard. */ - copy(password: string) { - const copyOptions = this.win != null ? { window: this.win } : undefined; - this.platformUtilsService.copyToClipboard(password, copyOptions); - this.toastService.showToast({ - variant: "info", - title: "", - message: this.i18nService.t("passwordCopied"), - }); - } - /** Retrieve the password history for the given cipher */ protected async init() { const cipher = await this.cipherService.get(this.cipherId); diff --git a/libs/vault/src/services/copy-cipher-field.service.ts b/libs/vault/src/services/copy-cipher-field.service.ts index 4767ae01bca..1867b10cd17 100644 --- a/libs/vault/src/services/copy-cipher-field.service.ts +++ b/libs/vault/src/services/copy-cipher-field.service.ts @@ -25,7 +25,10 @@ export type CopyAction = | "phone" | "address" | "secureNote" - | "hiddenField"; + | "hiddenField" + | "privateKey" + | "publicKey" + | "keyFingerprint"; type CopyActionInfo = { /** @@ -62,6 +65,9 @@ const CopyActions: Record = { phone: { typeI18nKey: "phone", protected: true }, address: { typeI18nKey: "address", protected: true }, secureNote: { typeI18nKey: "note", protected: true }, + privateKey: { typeI18nKey: "sshPrivateKey", protected: true }, + publicKey: { typeI18nKey: "sshPublicKey", protected: true }, + keyFingerprint: { typeI18nKey: "sshFingerprint", protected: true }, hiddenField: { typeI18nKey: "value", protected: true, diff --git a/package-lock.json b/package-lock.json index ab1c7d90655..e71e8c387d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,7 @@ "bufferutil": "4.0.8", "chalk": "4.1.2", "commander": "11.1.0", - "core-js": "3.36.1", + "core-js": "3.39.0", "form-data": "4.0.0", "https-proxy-agent": "7.0.5", "inquirer": "8.2.6", @@ -68,7 +68,7 @@ "qrious": "4.0.2", "rxjs": "7.8.1", "tabbable": "6.2.0", - "tldts": "6.1.58", + "tldts": "6.1.60", "utf-8-validate": "6.0.5", "zone.js": "0.14.10", "zxcvbn": "4.4.2" @@ -134,7 +134,7 @@ "css-loader": "7.1.2", "electron": "32.1.1", "electron-builder": "24.13.3", - "electron-log": "5.0.1", + "electron-log": "5.2.2", "electron-reload": "2.0.0-alpha.1", "electron-store": "8.2.0", "electron-updater": "6.3.9", @@ -151,7 +151,7 @@ "gulp-json-editor": "2.6.0", "gulp-replace": "1.1.4", "gulp-zip": "6.0.0", - "html-loader": "5.0.0", + "html-loader": "5.1.0", "html-webpack-injector": "1.1.4", "html-webpack-plugin": "5.6.3", "husky": "9.1.4", @@ -162,7 +162,7 @@ "lint-staged": "15.2.8", "mini-css-extract-plugin": "2.9.1", "node-ipc": "9.2.1", - "postcss": "8.4.38", + "postcss": "8.4.47", "postcss-loader": "8.1.1", "prettier": "3.3.3", "prettier-plugin-tailwindcss": "0.6.8", @@ -182,7 +182,7 @@ "url": "0.11.4", "util": "0.12.5", "wait-on": "8.0.1", - "webpack": "5.94.0", + "webpack": "5.96.1", "webpack-cli": "5.1.4", "webpack-dev-server": "5.0.4", "webpack-node-externals": "3.0.0" @@ -225,7 +225,7 @@ "papaparse": "5.4.1", "proper-lockfile": "4.1.2", "rxjs": "7.8.1", - "tldts": "6.1.58", + "tldts": "6.1.60", "zxcvbn": "4.4.2" }, "bin": { @@ -924,6 +924,30 @@ } } }, + "node_modules/@angular-devkit/build-angular/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/@angular-devkit/build-angular/node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -1030,6 +1054,13 @@ "node": ">= 10" } }, + "node_modules/@angular-devkit/build-angular/node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, "node_modules/@angular-devkit/build-angular/node_modules/mini-css-extract-plugin": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.8.1.tgz", @@ -1226,6 +1257,53 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@angular-devkit/build-angular/node_modules/webpack": { + "version": "5.94.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", + "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.5", + "@webassemblyjs/ast": "^1.12.1", + "@webassemblyjs/wasm-edit": "^1.12.1", + "@webassemblyjs/wasm-parser": "^1.12.1", + "acorn": "^8.7.1", + "acorn-import-attributes": "^1.9.5", + "browserslist": "^4.21.10", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.1", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.2.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.10", + "watchpack": "^2.4.1", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, "node_modules/@angular-devkit/build-angular/node_modules/webpack-dev-server": { "version": "4.15.1", "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.1.tgz", @@ -1310,6 +1388,73 @@ "webpack": "^4.0.0 || ^5.0.0" } }, + "node_modules/@angular-devkit/build-angular/node_modules/webpack/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/webpack/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/webpack/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular-devkit/build-angular/node_modules/webpack/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/webpack/node_modules/watchpack": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", + "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/@angular-devkit/core": { "version": "18.2.11", "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-18.2.11.tgz", @@ -9203,6 +9348,28 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -14735,9 +14902,9 @@ } }, "node_modules/core-js": { - "version": "3.36.1", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.36.1.tgz", - "integrity": "sha512-BTvUrwxVBezj5SZ3f10ImnX2oRByMxql3EimVqMysepbC9EeMUOpLwdy6Eoili2x6E4kf+ZUB5k/+Jv55alPfA==", + "version": "3.39.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz", + "integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==", "hasInstallScript": true, "license": "MIT", "funding": { @@ -16272,9 +16439,9 @@ } }, "node_modules/electron-log": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.0.1.tgz", - "integrity": "sha512-x4wnwHg00h/onWQgjmvcdLV7Mrd9TZjxNs8LmXVpqvANDf4FsSs5wLlzOykWLcaFzR3+5hdVEQ8ctmrUxgHlPA==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.2.2.tgz", + "integrity": "sha512-fgvx6srjIHDowJD8WAAjoAXmiTyOz6JnGQoxOtk1mXw7o4S+HutuPHLCsk24xTXqWZgy4uO63NbedG+oEvldLw==", "dev": true, "license": "MIT", "engines": { @@ -21127,9 +21294,9 @@ "peer": true }, "node_modules/html-loader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/html-loader/-/html-loader-5.0.0.tgz", - "integrity": "sha512-puaGKdjdVVIFRtgIC2n5dt5bt0N5j6heXlAQZ4Do1MLjHmOT1gCE1Ogg7XZNeJlnOVHHsrZKGs5dfh+XwZ3XPw==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/html-loader/-/html-loader-5.1.0.tgz", + "integrity": "sha512-Jb3xwDbsm0W3qlXrCZwcYqYGnYz55hb6aoKQTlzyZPXsPpi6tHXzAfqalecglMQgNvtEfxrCQPaKT90Irt5XDA==", "dev": true, "license": "MIT", "dependencies": { @@ -30889,9 +31056,9 @@ } }, "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "dev": true, "funding": [ { @@ -30910,8 +31077,8 @@ "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -35926,21 +36093,21 @@ } }, "node_modules/tldts": { - "version": "6.1.58", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.58.tgz", - "integrity": "sha512-MQJrJhjHOYGYb8DobR6Y4AdDbd4TYkyQ+KBDVc5ODzs1cbrvPpfN1IemYi9jfipJ/vR1YWvrDli0hg1y19VRoA==", + "version": "6.1.60", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.60.tgz", + "integrity": "sha512-TYVHm7G9NCnhgqOsFalbX6MG1Po5F4efF+tLfoeiOGQq48Oqgwcgz8upY2R1BHWa4aDrj28RYx0dkYJ63qCFMg==", "license": "MIT", "dependencies": { - "tldts-core": "^6.1.58" + "tldts-core": "^6.1.60" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "6.1.58", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.58.tgz", - "integrity": "sha512-dR936xmhBm7AeqHIhCWwK765gZ7dFyL+IqLSFAjJbFlUXGMLCb8i2PzlzaOuWBuplBTaBYseSb565nk/ZEM0Bg==", + "version": "6.1.60", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.60.tgz", + "integrity": "sha512-XHjoxak8SFQnHnmYHb3PcnW5TZ+9ErLZemZei3azuIRhQLw4IExsVbL3VZJdHcLeNaXq6NqawgpDPpjBOg4B5g==", "license": "MIT" }, "node_modules/tmp": { @@ -38412,19 +38579,19 @@ } }, "node_modules/webpack": { - "version": "5.94.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", - "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", + "version": "5.96.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.96.1.tgz", + "integrity": "sha512-l2LlBSvVZGhL4ZrPwyr8+37AunkcYj5qh8o6u2/2rzoPc8gxFJkLj1WxNgooi9pnoc06jh0BjuXnamM4qlujZA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "^1.0.5", + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.6", "@webassemblyjs/ast": "^1.12.1", "@webassemblyjs/wasm-edit": "^1.12.1", "@webassemblyjs/wasm-parser": "^1.12.1", - "acorn": "^8.7.1", - "acorn-import-attributes": "^1.9.5", - "browserslist": "^4.21.10", + "acorn": "^8.14.0", + "browserslist": "^4.24.0", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.17.1", "es-module-lexer": "^1.2.1", @@ -38853,6 +39020,39 @@ "ajv": "^6.9.1" } }, + "node_modules/webpack/node_modules/browserslist": { + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", + "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001669", + "electron-to-chromium": "^1.5.41", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.1" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/webpack/node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", diff --git a/package.json b/package.json index 1018a1bd262..282a63f2351 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,7 @@ "css-loader": "7.1.2", "electron": "32.1.1", "electron-builder": "24.13.3", - "electron-log": "5.0.1", + "electron-log": "5.2.2", "electron-reload": "2.0.0-alpha.1", "electron-store": "8.2.0", "electron-updater": "6.3.9", @@ -112,7 +112,7 @@ "gulp-json-editor": "2.6.0", "gulp-replace": "1.1.4", "gulp-zip": "6.0.0", - "html-loader": "5.0.0", + "html-loader": "5.1.0", "html-webpack-injector": "1.1.4", "html-webpack-plugin": "5.6.3", "husky": "9.1.4", @@ -123,7 +123,7 @@ "lint-staged": "15.2.8", "mini-css-extract-plugin": "2.9.1", "node-ipc": "9.2.1", - "postcss": "8.4.38", + "postcss": "8.4.47", "postcss-loader": "8.1.1", "prettier": "3.3.3", "prettier-plugin-tailwindcss": "0.6.8", @@ -143,7 +143,7 @@ "url": "0.11.4", "util": "0.12.5", "wait-on": "8.0.1", - "webpack": "5.94.0", + "webpack": "5.96.1", "webpack-cli": "5.1.4", "webpack-dev-server": "5.0.4", "webpack-node-externals": "3.0.0" @@ -174,7 +174,7 @@ "bufferutil": "4.0.8", "chalk": "4.1.2", "commander": "11.1.0", - "core-js": "3.36.1", + "core-js": "3.39.0", "form-data": "4.0.0", "https-proxy-agent": "7.0.5", "inquirer": "8.2.6", @@ -202,7 +202,7 @@ "qrious": "4.0.2", "rxjs": "7.8.1", "tabbable": "6.2.0", - "tldts": "6.1.58", + "tldts": "6.1.60", "utf-8-validate": "6.0.5", "zone.js": "0.14.10", "zxcvbn": "4.4.2"