mirror of
https://github.com/bitwarden/browser
synced 2026-02-13 06:54:07 +00:00
Merge branch 'main' into ps/extension-refresh
This commit is contained in:
67
.github/workflows/build-desktop.yml
vendored
67
.github/workflows/build-desktop.yml
vendored
@@ -738,7 +738,7 @@ jobs:
|
||||
$package = Get-Content -Raw -Path electron-builder.json | ConvertFrom-Json
|
||||
$package | Add-Member -MemberType NoteProperty -Name buildVersion -Value "$env:BUILD_NUMBER"
|
||||
$package | ConvertTo-Json -Depth 32 | Set-Content -Path electron-builder.json
|
||||
"### MacOS GitHub build number: $env:BUILD_NUMBER" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append
|
||||
Write-Output "### MacOS GitHub build number: $env:BUILD_NUMBER"
|
||||
|
||||
- name: Install Node dependencies
|
||||
run: npm ci
|
||||
@@ -879,6 +879,13 @@ jobs:
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
- name: Retrieve Slack secret
|
||||
id: retrieve-slack-secret
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: bitwarden-ci
|
||||
secrets: "slack-bot-token"
|
||||
|
||||
- name: Download Provisioning Profiles secrets
|
||||
env:
|
||||
ACCOUNT_NAME: bitwardenci
|
||||
@@ -955,7 +962,7 @@ jobs:
|
||||
$package = Get-Content -Raw -Path electron-builder.json | ConvertFrom-Json
|
||||
$package | Add-Member -MemberType NoteProperty -Name buildVersion -Value "$env:BUILD_NUMBER"
|
||||
$package | ConvertTo-Json -Depth 32 | Set-Content -Path electron-builder.json
|
||||
"### MacOS App Store build number: $env:BUILD_NUMBER" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append
|
||||
Write-Output "### MacOS App Store build number: $env:BUILD_NUMBER"
|
||||
|
||||
- name: Install Node dependencies
|
||||
run: npm ci
|
||||
@@ -1016,16 +1023,60 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Deploy to TestFlight
|
||||
id: testflight-deploy
|
||||
if: |
|
||||
(github.ref == 'refs/heads/main'
|
||||
&& needs.setup.outputs.rc_branch_exists == 0
|
||||
&& needs.setup.outputs.hotfix_branch_exists == 0)
|
||||
|| (github.ref == 'refs/heads/rc' && needs.setup.outputs.hotfix_branch_exists == 0)
|
||||
|| github.ref == 'refs/heads/hotfix-rc-desktop'
|
||||
|| 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
|
||||
run: npm run upload:mas
|
||||
run: |
|
||||
xcrun altool \
|
||||
--upload-app \
|
||||
--type macos \
|
||||
--file "$(find ./dist/mas-universal/Bitwarden*.pkg)" \
|
||||
--apiKey $APP_STORE_CONNECT_AUTH_KEY \
|
||||
--apiIssuer $APP_STORE_CONNECT_TEAM_ISSUER \
|
||||
&> output.txt
|
||||
|
||||
UUID=$(cat output.txt | grep "Delivery UUID" | sed -E 's/Delivery UUID: (.*)/\1/')
|
||||
echo "uuid=$UUID" >> $GITHUB_OUTPUT
|
||||
|
||||
- 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')
|
||||
uses: slackapi/slack-github-action@6c661ce58804a1a20f6dc5fbee7f0381b469e001 # v1.25.0
|
||||
with:
|
||||
channel-id: C074F5UESQ0
|
||||
payload: |
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "Desktop client v${{ env._PACKAGE_VERSION }} <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|build> success on *${{ github.ref_name }}*"
|
||||
},
|
||||
"accessory": {
|
||||
"type": "button",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "TestFlight Build",
|
||||
"emoji": true
|
||||
},
|
||||
"url": "https://appstoreconnect.apple.com/teams/${{ env.APP_STORE_CONNECT_TEAM_ISSUER }}/apps/1352778147/testflight/macos/${{ env.BUILD_UUID }}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
env:
|
||||
APP_STORE_CONNECT_TEAM_ISSUER: ${{ secrets.APP_STORE_CONNECT_TEAM_ISSUER }}
|
||||
SLACK_BOT_TOKEN: ${{ steps.retrieve-slack-secret.outputs.slack-bot-token }}
|
||||
BUILD_UUID: ${{ steps.testflight-deploy.outputs.uuid }}
|
||||
|
||||
|
||||
macos-package-dev:
|
||||
@@ -1158,7 +1209,7 @@ jobs:
|
||||
$package = Get-Content -Raw -Path electron-builder.json | ConvertFrom-Json
|
||||
$package | Add-Member -MemberType NoteProperty -Name buildVersion -Value "$env:BUILD_NUMBER"
|
||||
$package | ConvertTo-Json -Depth 32 | Set-Content -Path electron-builder.json
|
||||
"### MacOS Dev build number: $env:BUILD_NUMBER" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append
|
||||
Write-Output "### MacOS Dev build number: $env:BUILD_NUMBER"
|
||||
|
||||
- name: Install Node dependencies
|
||||
run: npm ci
|
||||
|
||||
15
.github/workflows/version-bump.yml
vendored
15
.github/workflows/version-bump.yml
vendored
@@ -83,8 +83,7 @@ jobs:
|
||||
with:
|
||||
keyvault: "bitwarden-ci"
|
||||
secrets: "github-gpg-private-key,
|
||||
github-gpg-private-key-passphrase,
|
||||
github-pat-bitwarden-devops-bot-repo-scope"
|
||||
github-gpg-private-key-passphrase"
|
||||
|
||||
- name: Import GPG key
|
||||
uses: crazy-max/ghaction-import-gpg@01dd5d3ca463c7f10f7f4f7b4f177225ac661ee4 # v6.1.0
|
||||
@@ -447,11 +446,19 @@ jobs:
|
||||
echo "$MESSAGE" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@3378cda945da322a8db4b193e19d46352ebe2de5 # v1.10.4
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.BW_GHAPP_ID }}
|
||||
private-key: ${{ secrets.BW_GHAPP_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Create Version PR
|
||||
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
|
||||
id: create-pr
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
PR_BRANCH: ${{ steps.create-branch.outputs.name }}
|
||||
TITLE: "Bump client(s) version"
|
||||
run: |
|
||||
@@ -483,7 +490,7 @@ jobs:
|
||||
- name: Merge PR
|
||||
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
PR_NUMBER: ${{ steps.create-pr.outputs.pr_number }}
|
||||
run: gh pr merge $PR_NUMBER --squash --auto --delete-branch
|
||||
|
||||
|
||||
@@ -328,7 +328,7 @@
|
||||
"createFoldersToOrganize": {
|
||||
"message": "Create folders to organize your vault items"
|
||||
},
|
||||
"deleteFolderPermanently":{
|
||||
"deleteFolderPermanently": {
|
||||
"message": "Are you sure you want to permanently delete this folder?"
|
||||
},
|
||||
"deleteFolder": {
|
||||
@@ -561,6 +561,9 @@
|
||||
"sessionTimeoutHeader": {
|
||||
"message": "Session timeout"
|
||||
},
|
||||
"vaultTimeoutHeader": {
|
||||
"message": "Vault timeout"
|
||||
},
|
||||
"otherOptions": {
|
||||
"message": "Other options"
|
||||
},
|
||||
@@ -601,6 +604,9 @@
|
||||
"vaultTimeout": {
|
||||
"message": "Vault timeout"
|
||||
},
|
||||
"vaultTimeout1": {
|
||||
"message": "Timeout"
|
||||
},
|
||||
"lockNow": {
|
||||
"message": "Lock now"
|
||||
},
|
||||
@@ -801,6 +807,12 @@
|
||||
"twoStepLoginConfirmation": {
|
||||
"message": "Two-step login makes your account more secure by requiring you to verify your login with another device such as a security key, authenticator app, SMS, phone call, or email. Two-step login can be set up on the bitwarden.com web vault. Do you want to visit the website now?"
|
||||
},
|
||||
"twoStepLoginConfirmationContent": {
|
||||
"message": "Make your account more secure by setting up two-step login in the Bitwarden web app."
|
||||
},
|
||||
"twoStepLoginConfirmationTitle": {
|
||||
"message": "Continue to web app?"
|
||||
},
|
||||
"editedFolder": {
|
||||
"message": "Folder saved"
|
||||
},
|
||||
@@ -1875,9 +1887,18 @@
|
||||
"unlockWithPin": {
|
||||
"message": "Unlock with PIN"
|
||||
},
|
||||
"setYourPinTitle": {
|
||||
"message": "Set PIN"
|
||||
},
|
||||
"setYourPinButton": {
|
||||
"message": "Set PIN"
|
||||
},
|
||||
"setYourPinCode": {
|
||||
"message": "Set your PIN code for unlocking Bitwarden. Your PIN settings will be reset if you ever fully log out of the application."
|
||||
},
|
||||
"setYourPinCode1": {
|
||||
"message": "Your PIN will be used to unlock Bitwarden instead of your master password. Your PIN will reset if you ever fully log out of Bitwarden."
|
||||
},
|
||||
"pinRequired": {
|
||||
"message": "PIN code is required."
|
||||
},
|
||||
@@ -1899,6 +1920,9 @@
|
||||
"lockWithMasterPassOnRestart": {
|
||||
"message": "Lock with master password on browser restart"
|
||||
},
|
||||
"lockWithMasterPassOnRestart1": {
|
||||
"message": "Require master password on browser restart"
|
||||
},
|
||||
"selectOneCollection": {
|
||||
"message": "You must select at least one collection."
|
||||
},
|
||||
@@ -1921,7 +1945,7 @@
|
||||
"message": "Use this password"
|
||||
},
|
||||
"useThisUsername": {
|
||||
"message": "Use this username"
|
||||
"message": "Use this username"
|
||||
},
|
||||
"securePasswordGenerated": {
|
||||
"message": "Secure password generated! Don't forget to also update your password on the website."
|
||||
@@ -1937,6 +1961,9 @@
|
||||
"vaultTimeoutAction": {
|
||||
"message": "Vault timeout action"
|
||||
},
|
||||
"vaultTimeoutAction1": {
|
||||
"message": "Timeout action"
|
||||
},
|
||||
"lock": {
|
||||
"message": "Lock",
|
||||
"description": "Verb form: to make secure or inaccessible by"
|
||||
@@ -2131,12 +2158,12 @@
|
||||
"nativeMessagingWrongUserTitle": {
|
||||
"message": "Account missmatch"
|
||||
},
|
||||
"nativeMessagingWrongUserKeyDesc": {
|
||||
"message": "Biometric unlock failed. The biometric secret key failed to unlock the vault. Please try to set up biometrics again."
|
||||
},
|
||||
"nativeMessagingWrongUserKeyTitle": {
|
||||
"message": "Biometric key missmatch"
|
||||
},
|
||||
"nativeMessagingWrongUserKeyDesc": {
|
||||
"message": "Biometric unlock failed. The biometric secret key failed to unlock the vault. Please try to set up biometrics again."
|
||||
},
|
||||
"biometricsNotEnabledTitle": {
|
||||
"message": "Biometrics not set up"
|
||||
},
|
||||
@@ -2522,6 +2549,9 @@
|
||||
"minutes": {
|
||||
"message": "Minutes"
|
||||
},
|
||||
"vaultTimeoutPolicyAffectingOptions": {
|
||||
"message": "Enterprise policy requirements have been applied to your timeout options"
|
||||
},
|
||||
"vaultTimeoutPolicyInEffect": {
|
||||
"message": "Your organization policies have set your maximum allowed vault timeout to $HOURS$ hour(s) and $MINUTES$ minute(s).",
|
||||
"placeholders": {
|
||||
@@ -2535,6 +2565,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"vaultTimeoutPolicyInEffect1": {
|
||||
"message": "$HOURS$ hour(s) and $MINUTES$ minute(s) maximum.",
|
||||
"placeholders": {
|
||||
"hours": {
|
||||
"content": "$1",
|
||||
"example": "5"
|
||||
},
|
||||
"minutes": {
|
||||
"content": "$2",
|
||||
"example": "5"
|
||||
}
|
||||
}
|
||||
},
|
||||
"vaultTimeoutPolicyMaximumError": {
|
||||
"message": "Timeout exceeds the restriction set by your organization: $HOURS$ hour(s) and $MINUTES$ minute(s) maximum",
|
||||
"placeholders": {
|
||||
"hours": {
|
||||
"content": "$1",
|
||||
"example": "5"
|
||||
},
|
||||
"minutes": {
|
||||
"content": "$2",
|
||||
"example": "5"
|
||||
}
|
||||
}
|
||||
},
|
||||
"vaultTimeoutPolicyWithActionInEffect": {
|
||||
"message": "Your organization policies are affecting your vault timeout. Maximum allowed vault timeout is $HOURS$ hour(s) and $MINUTES$ minute(s). Your vault timeout action is set to $ACTION$.",
|
||||
"placeholders": {
|
||||
@@ -3264,7 +3320,7 @@
|
||||
"message": "Unlock your account, opens in a new window",
|
||||
"description": "Screen reader text (aria-label) for unlock account button in overlay"
|
||||
},
|
||||
"fillCredentialsFor": {
|
||||
"fillCredentialsFor": {
|
||||
"message": "Fill credentials for",
|
||||
"description": "Screen reader text for when overlay item is in focused"
|
||||
},
|
||||
@@ -4321,6 +4377,15 @@
|
||||
"enterprisePolicyRequirementsApplied": {
|
||||
"message": "Enterprise policy requirements have been applied to this setting"
|
||||
},
|
||||
"retry": {
|
||||
"message": "Retry"
|
||||
},
|
||||
"vaultCustomTimeoutMinimum": {
|
||||
"message": "Minimum custom timeout is 1 minute."
|
||||
},
|
||||
"additionalContentAvailable": {
|
||||
"message": "Additional content is available"
|
||||
},
|
||||
"fileSavedToDevice": {
|
||||
"message": "File saved to device. Manage from your device downloads."
|
||||
},
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<form [bitSubmit]="submit" [formGroup]="setPinForm">
|
||||
<bit-dialog>
|
||||
<div class="tw-font-semibold" bitDialogTitle>
|
||||
{{ "unlockWithPin" | i18n }}
|
||||
{{ "setYourPinTitle" | i18n }}
|
||||
</div>
|
||||
<div bitDialogContent>
|
||||
<p>
|
||||
{{ "setYourPinCode" | i18n }}
|
||||
{{ "setYourPinCode1" | i18n }}
|
||||
</p>
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "pin" | i18n }}</bit-label>
|
||||
@@ -22,12 +22,12 @@
|
||||
bitCheckbox
|
||||
formControlName="requireMasterPasswordOnClientRestart"
|
||||
/>
|
||||
<span>{{ "lockWithMasterPassOnRestart" | i18n }}</span>
|
||||
<span>{{ "lockWithMasterPassOnRestart1" | i18n }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div bitDialogFooter>
|
||||
<button type="submit" bitButton bitFormButton buttonType="primary">
|
||||
<span>{{ "ok" | i18n }}</span>
|
||||
<span>{{ "setYourPinButton" | i18n }}</span>
|
||||
</button>
|
||||
<button type="button" bitButton bitFormButton buttonType="secondary" bitDialogClose>
|
||||
{{ "cancel" | i18n }}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<app-header>
|
||||
<div class="left">
|
||||
<button type="button" routerLink="/tabs/settings">
|
||||
<span class="header-icon"><i class="bwi bwi-angle-left" aria-hidden="true"></i></span>
|
||||
<span>{{ "back" | i18n }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<h1 class="center">
|
||||
<span class="title">{{ "accountSecurity" | i18n }}</span>
|
||||
</h1>
|
||||
<div class="right">
|
||||
<app-pop-out></app-pop-out>
|
||||
</div>
|
||||
</app-header>
|
||||
<main tabindex="-1" [formGroup]="form">
|
||||
<div class="box list">
|
||||
<h2 class="box-header">{{ "unlockMethods" | i18n }}</h2>
|
||||
<div class="box-content single-line">
|
||||
<div class="box-content-row box-content-row-checkbox" appBoxRow *ngIf="supportsBiometric">
|
||||
<label for="biometric">{{ "unlockWithBiometrics" | i18n }}</label>
|
||||
<input id="biometric" type="checkbox" formControlName="biometric" />
|
||||
</div>
|
||||
<div
|
||||
class="box-content-row box-content-row-checkbox"
|
||||
appBoxRow
|
||||
*ngIf="supportsBiometric && this.form.value.biometric"
|
||||
>
|
||||
<label for="autoBiometricsPrompt">{{ "enableAutoBiometricsPrompt" | i18n }}</label>
|
||||
<input
|
||||
id="autoBiometricsPrompt"
|
||||
type="checkbox"
|
||||
(change)="updateAutoBiometricsPrompt()"
|
||||
formControlName="enableAutoBiometricsPrompt"
|
||||
/>
|
||||
</div>
|
||||
<div class="box-content-row box-content-row-checkbox" appBoxRow>
|
||||
<label for="pin">{{ "unlockWithPin" | i18n }}</label>
|
||||
<input id="pin" type="checkbox" formControlName="pin" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box list">
|
||||
<h2 class="box-header">{{ "sessionTimeoutHeader" | i18n }}</h2>
|
||||
<div class="box-content single-line">
|
||||
<app-callout type="info" *ngIf="vaultTimeoutPolicyCallout | async as policy">
|
||||
<span *ngIf="policy.timeout && policy.action">
|
||||
{{
|
||||
"vaultTimeoutPolicyWithActionInEffect"
|
||||
| i18n: policy.timeout.hours : policy.timeout.minutes : (policy.action | i18n)
|
||||
}}
|
||||
</span>
|
||||
<span *ngIf="policy.timeout && !policy.action">
|
||||
{{ "vaultTimeoutPolicyInEffect" | i18n: policy.timeout.hours : policy.timeout.minutes }}
|
||||
</span>
|
||||
<span *ngIf="!policy.timeout && policy.action">
|
||||
{{ "vaultTimeoutActionPolicyInEffect" | i18n: (policy.action | i18n) }}
|
||||
</span>
|
||||
</app-callout>
|
||||
<app-vault-timeout-input
|
||||
[vaultTimeoutOptions]="vaultTimeoutOptions"
|
||||
[formControl]="form.controls.vaultTimeout"
|
||||
ngDefaultControl
|
||||
>
|
||||
</app-vault-timeout-input>
|
||||
<div class="box-content-row display-block" appBoxRow>
|
||||
<label for="vaultTimeoutAction">{{ "vaultTimeoutAction" | i18n }}</label>
|
||||
<select
|
||||
id="vaultTimeoutAction"
|
||||
name="VaultTimeoutActions"
|
||||
formControlName="vaultTimeoutAction"
|
||||
>
|
||||
<option *ngFor="let action of availableVaultTimeoutActions" [ngValue]="action">
|
||||
{{ action | i18n }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="!availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock)"
|
||||
id="unlockMethodHelp"
|
||||
class="box-footer"
|
||||
>
|
||||
{{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box list">
|
||||
<h2 class="box-header">{{ "otherOptions" | i18n }}</h2>
|
||||
<div class="box-content single-line">
|
||||
<button
|
||||
type="button"
|
||||
class="box-content-row box-content-row-flex text-default"
|
||||
appStopClick
|
||||
(click)="fingerprint()"
|
||||
>
|
||||
<div class="row-main">{{ "fingerprintPhrase" | i18n }}</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="box-content-row box-content-row-flex text-default"
|
||||
appStopClick
|
||||
(click)="twoStep()"
|
||||
>
|
||||
<div class="row-main">{{ "twoStepLogin" | i18n }}</div>
|
||||
<i class="bwi bwi-external-link bwi-lg row-sub-icon" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="box-content-row box-content-row-flex text-default"
|
||||
appStopClick
|
||||
(click)="changePassword()"
|
||||
*ngIf="showChangeMasterPass"
|
||||
>
|
||||
<div class="row-main">{{ "changeMasterPassword" | i18n }}</div>
|
||||
<i class="bwi bwi-external-link bwi-lg row-sub-icon" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button
|
||||
*ngIf="
|
||||
!accountSwitcherEnabled && availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock)
|
||||
"
|
||||
type="button"
|
||||
class="box-content-row box-content-row-flex text-default"
|
||||
appStopClick
|
||||
(click)="lock()"
|
||||
>
|
||||
<div class="row-main">{{ "lockNow" | i18n }}</div>
|
||||
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button
|
||||
*ngIf="!accountSwitcherEnabled"
|
||||
type="button"
|
||||
class="box-content-row box-content-row-flex text-default"
|
||||
appStopClick
|
||||
(click)="logOut()"
|
||||
>
|
||||
<div class="row-main">{{ "logOut" | i18n }}</div>
|
||||
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -0,0 +1,501 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { FormBuilder } from "@angular/forms";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
concatMap,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
map,
|
||||
Observable,
|
||||
pairwise,
|
||||
startWith,
|
||||
Subject,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
} from "rxjs";
|
||||
|
||||
import { FingerprintDialogComponent } from "@bitwarden/auth/angular";
|
||||
import { PinServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service";
|
||||
import { BiometricsService } from "@bitwarden/common/platform/biometrics/biometric.service";
|
||||
import {
|
||||
VaultTimeout,
|
||||
VaultTimeoutOption,
|
||||
VaultTimeoutStringType,
|
||||
} from "@bitwarden/common/types/vault-timeout.type";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { BiometricErrors, BiometricErrorTypes } from "../../../models/biometricErrors";
|
||||
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||
import { enableAccountSwitching } from "../../../platform/flags";
|
||||
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
|
||||
import { SetPinComponent } from "../components/set-pin.component";
|
||||
|
||||
import { AwaitDesktopDialogComponent } from "./await-desktop-dialog.component";
|
||||
|
||||
@Component({
|
||||
selector: "auth-account-security",
|
||||
templateUrl: "account-security-v1.component.html",
|
||||
})
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||
export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
protected readonly VaultTimeoutAction = VaultTimeoutAction;
|
||||
|
||||
availableVaultTimeoutActions: VaultTimeoutAction[] = [];
|
||||
vaultTimeoutOptions: VaultTimeoutOption[];
|
||||
vaultTimeoutPolicyCallout: Observable<{
|
||||
timeout: { hours: number; minutes: number };
|
||||
action: VaultTimeoutAction;
|
||||
}>;
|
||||
supportsBiometric: boolean;
|
||||
showChangeMasterPass = true;
|
||||
accountSwitcherEnabled = false;
|
||||
|
||||
form = this.formBuilder.group({
|
||||
vaultTimeout: [null as VaultTimeout | null],
|
||||
vaultTimeoutAction: [VaultTimeoutAction.Lock],
|
||||
pin: [null as boolean | null],
|
||||
biometric: false,
|
||||
enableAutoBiometricsPrompt: true,
|
||||
});
|
||||
|
||||
private refreshTimeoutSettings$ = new BehaviorSubject<void>(undefined);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private pinService: PinServiceAbstraction,
|
||||
private policyService: PolicyService,
|
||||
private formBuilder: FormBuilder,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private i18nService: I18nService,
|
||||
private vaultTimeoutService: VaultTimeoutService,
|
||||
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||
public messagingService: MessagingService,
|
||||
private environmentService: EnvironmentService,
|
||||
private cryptoService: CryptoService,
|
||||
private stateService: StateService,
|
||||
private userVerificationService: UserVerificationService,
|
||||
private dialogService: DialogService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private biometricStateService: BiometricStateService,
|
||||
private biometricsService: BiometricsService,
|
||||
) {
|
||||
this.accountSwitcherEnabled = enableAccountSwitching();
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
const maximumVaultTimeoutPolicy = this.policyService.get$(PolicyType.MaximumVaultTimeout);
|
||||
this.vaultTimeoutPolicyCallout = maximumVaultTimeoutPolicy.pipe(
|
||||
filter((policy) => policy != null),
|
||||
map((policy) => {
|
||||
let timeout;
|
||||
if (policy.data?.minutes) {
|
||||
timeout = {
|
||||
hours: Math.floor(policy.data?.minutes / 60),
|
||||
minutes: policy.data?.minutes % 60,
|
||||
};
|
||||
}
|
||||
return { timeout: timeout, action: policy.data?.action };
|
||||
}),
|
||||
);
|
||||
|
||||
const showOnLocked =
|
||||
!this.platformUtilsService.isFirefox() && !this.platformUtilsService.isSafari();
|
||||
|
||||
this.vaultTimeoutOptions = [
|
||||
{ name: this.i18nService.t("immediately"), value: 0 },
|
||||
{ name: this.i18nService.t("oneMinute"), value: 1 },
|
||||
{ name: this.i18nService.t("fiveMinutes"), value: 5 },
|
||||
{ name: this.i18nService.t("fifteenMinutes"), value: 15 },
|
||||
{ name: this.i18nService.t("thirtyMinutes"), value: 30 },
|
||||
{ name: this.i18nService.t("oneHour"), value: 60 },
|
||||
{ name: this.i18nService.t("fourHours"), value: 240 },
|
||||
];
|
||||
|
||||
if (showOnLocked) {
|
||||
this.vaultTimeoutOptions.push({
|
||||
name: this.i18nService.t("onLocked"),
|
||||
value: VaultTimeoutStringType.OnLocked,
|
||||
});
|
||||
}
|
||||
|
||||
this.vaultTimeoutOptions.push({
|
||||
name: this.i18nService.t("onRestart"),
|
||||
value: VaultTimeoutStringType.OnRestart,
|
||||
});
|
||||
this.vaultTimeoutOptions.push({
|
||||
name: this.i18nService.t("never"),
|
||||
value: VaultTimeoutStringType.Never,
|
||||
});
|
||||
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
|
||||
let timeout = await firstValueFrom(
|
||||
this.vaultTimeoutSettingsService.getVaultTimeoutByUserId$(activeAccount.id),
|
||||
);
|
||||
if (timeout === VaultTimeoutStringType.OnLocked && !showOnLocked) {
|
||||
timeout = VaultTimeoutStringType.OnRestart;
|
||||
}
|
||||
|
||||
const initialValues = {
|
||||
vaultTimeout: timeout,
|
||||
vaultTimeoutAction: await firstValueFrom(
|
||||
this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(activeAccount.id),
|
||||
),
|
||||
pin: await this.pinService.isPinSet(activeAccount.id),
|
||||
biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(),
|
||||
enableAutoBiometricsPrompt: await firstValueFrom(
|
||||
this.biometricStateService.promptAutomatically$,
|
||||
),
|
||||
};
|
||||
this.form.patchValue(initialValues, { emitEvent: false });
|
||||
|
||||
this.supportsBiometric = await this.biometricsService.supportsBiometric();
|
||||
this.showChangeMasterPass = await this.userVerificationService.hasMasterPassword();
|
||||
|
||||
this.form.controls.vaultTimeout.valueChanges
|
||||
.pipe(
|
||||
startWith(initialValues.vaultTimeout), // emit to init pairwise
|
||||
pairwise(),
|
||||
concatMap(async ([previousValue, newValue]) => {
|
||||
await this.saveVaultTimeout(previousValue, newValue);
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.form.controls.vaultTimeoutAction.valueChanges
|
||||
.pipe(
|
||||
startWith(initialValues.vaultTimeoutAction), // emit to init pairwise
|
||||
pairwise(),
|
||||
concatMap(async ([previousValue, newValue]) => {
|
||||
await this.saveVaultTimeoutAction(previousValue, newValue);
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.form.controls.pin.valueChanges
|
||||
.pipe(
|
||||
concatMap(async (value) => {
|
||||
await this.updatePin(value);
|
||||
this.refreshTimeoutSettings$.next();
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.form.controls.biometric.valueChanges
|
||||
.pipe(
|
||||
distinctUntilChanged(),
|
||||
concatMap(async (enabled) => {
|
||||
await this.updateBiometric(enabled);
|
||||
if (enabled) {
|
||||
this.form.controls.enableAutoBiometricsPrompt.enable();
|
||||
} else {
|
||||
this.form.controls.enableAutoBiometricsPrompt.disable();
|
||||
}
|
||||
this.refreshTimeoutSettings$.next();
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.refreshTimeoutSettings$
|
||||
.pipe(
|
||||
switchMap(() =>
|
||||
combineLatest([
|
||||
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(activeAccount.id),
|
||||
]),
|
||||
),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe(([availableActions, action]) => {
|
||||
this.availableVaultTimeoutActions = availableActions;
|
||||
this.form.controls.vaultTimeoutAction.setValue(action, { emitEvent: false });
|
||||
// NOTE: The UI doesn't properly update without detect changes.
|
||||
// I've even tried using an async pipe, but it still doesn't work. I'm not sure why.
|
||||
// Using an async pipe means that we can't call `detectChanges` AFTER the data has change
|
||||
// meaning that we are forced to use regular class variables instead of observables.
|
||||
this.changeDetectorRef.detectChanges();
|
||||
});
|
||||
|
||||
this.refreshTimeoutSettings$
|
||||
.pipe(
|
||||
switchMap(() =>
|
||||
combineLatest([
|
||||
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
maximumVaultTimeoutPolicy,
|
||||
]),
|
||||
),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe(([availableActions, policy]) => {
|
||||
if (policy?.data?.action || availableActions.length <= 1) {
|
||||
this.form.controls.vaultTimeoutAction.disable({ emitEvent: false });
|
||||
} else {
|
||||
this.form.controls.vaultTimeoutAction.enable({ emitEvent: false });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async saveVaultTimeout(previousValue: VaultTimeout, newValue: VaultTimeout) {
|
||||
if (newValue === VaultTimeoutStringType.Never) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "warning" },
|
||||
content: { key: "neverLockWarning" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
this.form.controls.vaultTimeout.setValue(previousValue, { emitEvent: false });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// The minTimeoutError does not apply to browser because it supports Immediately
|
||||
// So only check for the policyError
|
||||
if (this.form.controls.vaultTimeout.hasError("policyError")) {
|
||||
this.platformUtilsService.showToast(
|
||||
"error",
|
||||
null,
|
||||
this.i18nService.t("vaultTimeoutTooLarge"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
|
||||
const vaultTimeoutAction = await firstValueFrom(
|
||||
this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(activeAccount.id),
|
||||
);
|
||||
|
||||
await this.vaultTimeoutSettingsService.setVaultTimeoutOptions(
|
||||
activeAccount.id,
|
||||
newValue,
|
||||
vaultTimeoutAction,
|
||||
);
|
||||
if (newValue === VaultTimeoutStringType.Never) {
|
||||
this.messagingService.send("bgReseedStorage");
|
||||
}
|
||||
}
|
||||
|
||||
async saveVaultTimeoutAction(previousValue: VaultTimeoutAction, newValue: VaultTimeoutAction) {
|
||||
if (newValue === VaultTimeoutAction.LogOut) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "vaultTimeoutLogOutConfirmationTitle" },
|
||||
content: { key: "vaultTimeoutLogOutConfirmation" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
this.form.controls.vaultTimeoutAction.setValue(previousValue, {
|
||||
emitEvent: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.form.controls.vaultTimeout.hasError("policyError")) {
|
||||
this.platformUtilsService.showToast(
|
||||
"error",
|
||||
null,
|
||||
this.i18nService.t("vaultTimeoutTooLarge"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
|
||||
await this.vaultTimeoutSettingsService.setVaultTimeoutOptions(
|
||||
activeAccount.id,
|
||||
this.form.value.vaultTimeout,
|
||||
newValue,
|
||||
);
|
||||
this.refreshTimeoutSettings$.next();
|
||||
}
|
||||
|
||||
async updatePin(value: boolean) {
|
||||
if (value) {
|
||||
const dialogRef = SetPinComponent.open(this.dialogService);
|
||||
|
||||
if (dialogRef == null) {
|
||||
this.form.controls.pin.setValue(false, { emitEvent: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const userHasPinSet = await firstValueFrom(dialogRef.closed);
|
||||
this.form.controls.pin.setValue(userHasPinSet, { emitEvent: false });
|
||||
} else {
|
||||
await this.vaultTimeoutSettingsService.clear();
|
||||
}
|
||||
}
|
||||
|
||||
async updateBiometric(enabled: boolean) {
|
||||
if (enabled && this.supportsBiometric) {
|
||||
let granted;
|
||||
try {
|
||||
granted = await BrowserApi.requestPermission({ permissions: ["nativeMessaging"] });
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line
|
||||
console.error(e);
|
||||
|
||||
if (this.platformUtilsService.isFirefox() && BrowserPopupUtils.inSidebar(window)) {
|
||||
await this.dialogService.openSimpleDialog({
|
||||
title: { key: "nativeMessaginPermissionSidebarTitle" },
|
||||
content: { key: "nativeMessaginPermissionSidebarDesc" },
|
||||
acceptButtonText: { key: "ok" },
|
||||
cancelButtonText: null,
|
||||
type: "info",
|
||||
});
|
||||
|
||||
this.form.controls.biometric.setValue(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!granted) {
|
||||
await this.dialogService.openSimpleDialog({
|
||||
title: { key: "nativeMessaginPermissionErrorTitle" },
|
||||
content: { key: "nativeMessaginPermissionErrorDesc" },
|
||||
acceptButtonText: { key: "ok" },
|
||||
cancelButtonText: null,
|
||||
type: "danger",
|
||||
});
|
||||
|
||||
this.form.controls.biometric.setValue(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const awaitDesktopDialogRef = AwaitDesktopDialogComponent.open(this.dialogService);
|
||||
const awaitDesktopDialogClosed = firstValueFrom(awaitDesktopDialogRef.closed);
|
||||
|
||||
await this.cryptoService.refreshAdditionalKeys();
|
||||
|
||||
await Promise.race([
|
||||
awaitDesktopDialogClosed.then(async (result) => {
|
||||
if (result !== true) {
|
||||
this.form.controls.biometric.setValue(false);
|
||||
}
|
||||
}),
|
||||
this.biometricsService
|
||||
.authenticateBiometric()
|
||||
.then((result) => {
|
||||
this.form.controls.biometric.setValue(result);
|
||||
if (!result) {
|
||||
this.platformUtilsService.showToast(
|
||||
"error",
|
||||
this.i18nService.t("errorEnableBiometricTitle"),
|
||||
this.i18nService.t("errorEnableBiometricDesc"),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
// Handle connection errors
|
||||
this.form.controls.biometric.setValue(false);
|
||||
|
||||
const error = BiometricErrors[e.message as BiometricErrorTypes];
|
||||
|
||||
// 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.dialogService.openSimpleDialog({
|
||||
title: { key: error.title },
|
||||
content: { key: error.description },
|
||||
acceptButtonText: { key: "ok" },
|
||||
cancelButtonText: null,
|
||||
type: "danger",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
awaitDesktopDialogRef.close(true);
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
await this.biometricStateService.setBiometricUnlockEnabled(false);
|
||||
await this.biometricStateService.setFingerprintValidated(false);
|
||||
}
|
||||
}
|
||||
|
||||
async updateAutoBiometricsPrompt() {
|
||||
await this.biometricStateService.setPromptAutomatically(
|
||||
this.form.value.enableAutoBiometricsPrompt,
|
||||
);
|
||||
}
|
||||
|
||||
async changePassword() {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "continueToWebApp" },
|
||||
content: { key: "changeMasterPasswordOnWebConfirmation" },
|
||||
type: "info",
|
||||
acceptButtonText: { key: "continue" },
|
||||
});
|
||||
if (confirmed) {
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
await BrowserApi.createNewTab(env.getWebVaultUrl());
|
||||
}
|
||||
}
|
||||
|
||||
async twoStep() {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "twoStepLogin" },
|
||||
content: { key: "twoStepLoginConfirmation" },
|
||||
type: "info",
|
||||
});
|
||||
if (confirmed) {
|
||||
// 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
|
||||
BrowserApi.createNewTab("https://bitwarden.com/help/setup-two-step-login/");
|
||||
}
|
||||
}
|
||||
|
||||
async fingerprint() {
|
||||
const fingerprint = await this.cryptoService.getFingerprint(
|
||||
await this.stateService.getUserId(),
|
||||
);
|
||||
|
||||
const dialogRef = FingerprintDialogComponent.open(this.dialogService, {
|
||||
fingerprint,
|
||||
});
|
||||
|
||||
return firstValueFrom(dialogRef.closed);
|
||||
}
|
||||
|
||||
async lock() {
|
||||
await this.vaultTimeoutService.lock();
|
||||
}
|
||||
|
||||
async logOut() {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "logOut" },
|
||||
content: { key: "logOutConfirmation" },
|
||||
type: "info",
|
||||
});
|
||||
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
if (confirmed) {
|
||||
this.messagingService.send("logout", { userId: userId });
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
@@ -1,140 +1,126 @@
|
||||
<app-header>
|
||||
<div class="left">
|
||||
<button type="button" routerLink="/tabs/settings">
|
||||
<span class="header-icon"><i class="bwi bwi-angle-left" aria-hidden="true"></i></span>
|
||||
<span>{{ "back" | i18n }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<h1 class="center">
|
||||
<span class="title">{{ "accountSecurity" | i18n }}</span>
|
||||
</h1>
|
||||
<div class="right">
|
||||
<app-pop-out></app-pop-out>
|
||||
</div>
|
||||
</app-header>
|
||||
<main tabindex="-1" [formGroup]="form">
|
||||
<div class="box list">
|
||||
<h2 class="box-header">{{ "unlockMethods" | i18n }}</h2>
|
||||
<div class="box-content single-line">
|
||||
<div class="box-content-row box-content-row-checkbox" appBoxRow *ngIf="supportsBiometric">
|
||||
<label for="biometric">{{ "unlockWithBiometrics" | i18n }}</label>
|
||||
<input id="biometric" type="checkbox" formControlName="biometric" />
|
||||
</div>
|
||||
<div
|
||||
class="box-content-row box-content-row-checkbox"
|
||||
appBoxRow
|
||||
*ngIf="supportsBiometric && this.form.value.biometric"
|
||||
>
|
||||
<label for="autoBiometricsPrompt">{{ "enableAutoBiometricsPrompt" | i18n }}</label>
|
||||
<input
|
||||
id="autoBiometricsPrompt"
|
||||
type="checkbox"
|
||||
(change)="updateAutoBiometricsPrompt()"
|
||||
formControlName="enableAutoBiometricsPrompt"
|
||||
/>
|
||||
</div>
|
||||
<div class="box-content-row box-content-row-checkbox" appBoxRow>
|
||||
<label for="pin">{{ "unlockWithPin" | i18n }}</label>
|
||||
<input id="pin" type="checkbox" formControlName="pin" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box list">
|
||||
<h2 class="box-header">{{ "sessionTimeoutHeader" | i18n }}</h2>
|
||||
<div class="box-content single-line">
|
||||
<app-callout type="info" *ngIf="vaultTimeoutPolicyCallout | async as policy">
|
||||
<span *ngIf="policy.timeout && policy.action">
|
||||
{{
|
||||
"vaultTimeoutPolicyWithActionInEffect"
|
||||
| i18n: policy.timeout.hours : policy.timeout.minutes : (policy.action | i18n)
|
||||
}}
|
||||
</span>
|
||||
<span *ngIf="policy.timeout && !policy.action">
|
||||
{{ "vaultTimeoutPolicyInEffect" | i18n: policy.timeout.hours : policy.timeout.minutes }}
|
||||
</span>
|
||||
<span *ngIf="!policy.timeout && policy.action">
|
||||
{{ "vaultTimeoutActionPolicyInEffect" | i18n: (policy.action | i18n) }}
|
||||
</span>
|
||||
</app-callout>
|
||||
<app-vault-timeout-input
|
||||
[vaultTimeoutOptions]="vaultTimeoutOptions"
|
||||
[formControl]="form.controls.vaultTimeout"
|
||||
ngDefaultControl
|
||||
>
|
||||
</app-vault-timeout-input>
|
||||
<div class="box-content-row display-block" appBoxRow>
|
||||
<label for="vaultTimeoutAction">{{ "vaultTimeoutAction" | i18n }}</label>
|
||||
<select
|
||||
id="vaultTimeoutAction"
|
||||
name="VaultTimeoutActions"
|
||||
formControlName="vaultTimeoutAction"
|
||||
<popup-page>
|
||||
<popup-header slot="header" pageTitle="{{ 'accountSecurity' | i18n }}" showBackButton>
|
||||
<ng-container slot="end">
|
||||
<app-pop-out></app-pop-out>
|
||||
</ng-container>
|
||||
</popup-header>
|
||||
|
||||
<div class="tw-bg-background-alt tw-p-2" [formGroup]="form">
|
||||
<bit-section>
|
||||
<bit-section-header>
|
||||
<h2 bitTypography="h6">{{ "unlockMethods" | i18n }}</h2>
|
||||
</bit-section-header>
|
||||
<bit-card>
|
||||
<bit-form-control *ngIf="supportsBiometric">
|
||||
<input bitCheckbox id="biometric" type="checkbox" formControlName="biometric" />
|
||||
<bit-label for="biometric" class="tw-whitespace-normal">{{
|
||||
"unlockWithBiometrics" | i18n
|
||||
}}</bit-label>
|
||||
</bit-form-control>
|
||||
<bit-form-control class="tw-pl-5" *ngIf="supportsBiometric && this.form.value.biometric">
|
||||
<input
|
||||
bitCheckbox
|
||||
id="autoBiometricsPrompt"
|
||||
type="checkbox"
|
||||
formControlName="enableAutoBiometricsPrompt"
|
||||
/>
|
||||
<bit-label for="autoBiometricsPrompt" class="tw-whitespace-normal">{{
|
||||
"enableAutoBiometricsPrompt" | i18n
|
||||
}}</bit-label>
|
||||
</bit-form-control>
|
||||
<bit-form-control
|
||||
[disableMargin]="!(this.form.value.pin && showMasterPasswordOnClientRestartOption)"
|
||||
>
|
||||
<option *ngFor="let action of availableVaultTimeoutActions" [ngValue]="action">
|
||||
{{ action | i18n }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="!availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock)"
|
||||
id="unlockMethodHelp"
|
||||
class="box-footer"
|
||||
>
|
||||
{{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box list">
|
||||
<h2 class="box-header">{{ "otherOptions" | i18n }}</h2>
|
||||
<div class="box-content single-line">
|
||||
<button
|
||||
type="button"
|
||||
class="box-content-row box-content-row-flex text-default"
|
||||
appStopClick
|
||||
(click)="fingerprint()"
|
||||
>
|
||||
<div class="row-main">{{ "fingerprintPhrase" | i18n }}</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="box-content-row box-content-row-flex text-default"
|
||||
appStopClick
|
||||
(click)="twoStep()"
|
||||
>
|
||||
<div class="row-main">{{ "twoStepLogin" | i18n }}</div>
|
||||
<i class="bwi bwi-external-link bwi-lg row-sub-icon" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="box-content-row box-content-row-flex text-default"
|
||||
appStopClick
|
||||
(click)="changePassword()"
|
||||
*ngIf="showChangeMasterPass"
|
||||
>
|
||||
<div class="row-main">{{ "changeMasterPassword" | i18n }}</div>
|
||||
<i class="bwi bwi-external-link bwi-lg row-sub-icon" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button
|
||||
<input bitCheckbox id="pin" type="checkbox" formControlName="pin" />
|
||||
<bit-label for="pin" class="tw-whitespace-normal">{{ "unlockWithPin" | i18n }}</bit-label>
|
||||
</bit-form-control>
|
||||
<bit-form-control
|
||||
class="tw-pl-5"
|
||||
disableMargin
|
||||
*ngIf="this.form.value.pin && showMasterPasswordOnClientRestartOption"
|
||||
>
|
||||
<input
|
||||
bitCheckbox
|
||||
id="pinEphemeral"
|
||||
type="checkbox"
|
||||
formControlName="pinLockWithMasterPassword"
|
||||
/>
|
||||
<bit-label for="pinEphemeral" class="tw-whitespace-normal">{{
|
||||
"lockWithMasterPassOnRestart1" | i18n
|
||||
}}</bit-label>
|
||||
</bit-form-control>
|
||||
</bit-card>
|
||||
</bit-section>
|
||||
<bit-section>
|
||||
<bit-section-header>
|
||||
<h2 bitTypography="h6">{{ "vaultTimeoutHeader" | i18n }}</h2>
|
||||
</bit-section-header>
|
||||
|
||||
<bit-card>
|
||||
<auth-vault-timeout-input
|
||||
[vaultTimeoutOptions]="vaultTimeoutOptions"
|
||||
[formControl]="form.controls.vaultTimeout"
|
||||
ngDefaultControl
|
||||
>
|
||||
</auth-vault-timeout-input>
|
||||
<bit-form-field disableMargin>
|
||||
<bit-label for="vaultTimeoutAction">{{ "vaultTimeoutAction1" | i18n }}</bit-label>
|
||||
<select
|
||||
aria-describedby="vaultTimeoutActionHelp"
|
||||
bitInput
|
||||
id="vaultTimeoutAction"
|
||||
name="VaultTimeoutActions"
|
||||
formControlName="vaultTimeoutAction"
|
||||
>
|
||||
<option *ngFor="let action of availableVaultTimeoutActions" [ngValue]="action">
|
||||
{{ action | i18n }}
|
||||
</option>
|
||||
</select>
|
||||
<bit-hint class="tw-text-sm" id="vaultTimeoutActionHelp">
|
||||
{{ "vaultTimeoutActionDesc" | i18n }}
|
||||
</bit-hint>
|
||||
<bit-hint *ngIf="!availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock)">
|
||||
{{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}<br />
|
||||
</bit-hint>
|
||||
</bit-form-field>
|
||||
<bit-hint *ngIf="hasVaultTimeoutPolicy">
|
||||
{{ "vaultTimeoutPolicyAffectingOptions" | i18n }}
|
||||
</bit-hint>
|
||||
</bit-card>
|
||||
</bit-section>
|
||||
<bit-section>
|
||||
<bit-section-header>
|
||||
<h2 bitTypography="h6">{{ "otherOptions" | i18n }}</h2>
|
||||
</bit-section-header>
|
||||
<bit-item>
|
||||
<button bit-item-content type="button" appStopClick (click)="fingerprint()">
|
||||
<div class="row-main">{{ "fingerprintPhrase" | i18n }}</div>
|
||||
</button>
|
||||
</bit-item>
|
||||
<bit-item>
|
||||
<button bit-item-content type="button" appStopClick (click)="twoStep()">
|
||||
{{ "twoStepLogin" | i18n }}
|
||||
<i slot="end" class="bwi bwi-external-link" aria-hidden="true"></i>
|
||||
</button>
|
||||
</bit-item>
|
||||
<bit-item *ngIf="showChangeMasterPass">
|
||||
<button bit-item-content type="button" appStopClick (click)="changePassword()">
|
||||
{{ "changeMasterPassword" | i18n }}
|
||||
<i slot="end" class="bwi bwi-external-link" aria-hidden="true"></i>
|
||||
</button>
|
||||
</bit-item>
|
||||
<bit-item
|
||||
*ngIf="
|
||||
!accountSwitcherEnabled && availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock)
|
||||
"
|
||||
type="button"
|
||||
class="box-content-row box-content-row-flex text-default"
|
||||
appStopClick
|
||||
(click)="lock()"
|
||||
>
|
||||
<button bit-item-content type="button" appStopClick (click)="lock()"></button>
|
||||
<div class="row-main">{{ "lockNow" | i18n }}</div>
|
||||
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button
|
||||
*ngIf="!accountSwitcherEnabled"
|
||||
type="button"
|
||||
class="box-content-row box-content-row-flex text-default"
|
||||
appStopClick
|
||||
(click)="logOut()"
|
||||
>
|
||||
</bit-item>
|
||||
<bit-item *ngIf="!accountSwitcherEnabled">
|
||||
<button bit-item-content type="button" appStopClick (click)="logOut()"></button>
|
||||
<div class="row-main">{{ "logOut" | i18n }}</div>
|
||||
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</bit-item>
|
||||
</bit-section>
|
||||
</div>
|
||||
</main>
|
||||
</popup-page>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { DialogRef } from "@angular/cdk/dialog";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { FormBuilder } from "@angular/forms";
|
||||
import { FormBuilder, FormsModule, ReactiveFormsModule } from "@angular/forms";
|
||||
import { RouterModule } from "@angular/router";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
concatMap,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
map,
|
||||
Observable,
|
||||
pairwise,
|
||||
startWith,
|
||||
Subject,
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
takeUntil,
|
||||
} from "rxjs";
|
||||
|
||||
import { FingerprintDialogComponent } from "@bitwarden/auth/angular";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { FingerprintDialogComponent, VaultTimeoutInputComponent } from "@bitwarden/auth/angular";
|
||||
import { PinServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
|
||||
@@ -39,30 +40,67 @@ import {
|
||||
VaultTimeoutOption,
|
||||
VaultTimeoutStringType,
|
||||
} from "@bitwarden/common/types/vault-timeout.type";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import {
|
||||
CardComponent,
|
||||
CheckboxModule,
|
||||
DialogService,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
ItemModule,
|
||||
LinkModule,
|
||||
SectionComponent,
|
||||
SectionHeaderComponent,
|
||||
SelectModule,
|
||||
TypographyModule,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { BiometricErrors, BiometricErrorTypes } from "../../../models/biometricErrors";
|
||||
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||
import { enableAccountSwitching } from "../../../platform/flags";
|
||||
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
|
||||
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
|
||||
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";
|
||||
import { SetPinComponent } from "../components/set-pin.component";
|
||||
|
||||
import { AwaitDesktopDialogComponent } from "./await-desktop-dialog.component";
|
||||
|
||||
@Component({
|
||||
selector: "auth-account-security",
|
||||
templateUrl: "account-security.component.html",
|
||||
standalone: true,
|
||||
imports: [
|
||||
CardComponent,
|
||||
CheckboxModule,
|
||||
CommonModule,
|
||||
FormFieldModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
IconButtonModule,
|
||||
ItemModule,
|
||||
JslibModule,
|
||||
LinkModule,
|
||||
PopOutComponent,
|
||||
PopupFooterComponent,
|
||||
PopupHeaderComponent,
|
||||
PopupPageComponent,
|
||||
RouterModule,
|
||||
SectionComponent,
|
||||
SectionHeaderComponent,
|
||||
SelectModule,
|
||||
TypographyModule,
|
||||
VaultTimeoutInputComponent,
|
||||
],
|
||||
})
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||
export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
protected readonly VaultTimeoutAction = VaultTimeoutAction;
|
||||
|
||||
showMasterPasswordOnClientRestartOption = true;
|
||||
availableVaultTimeoutActions: VaultTimeoutAction[] = [];
|
||||
vaultTimeoutOptions: VaultTimeoutOption[];
|
||||
vaultTimeoutPolicyCallout: Observable<{
|
||||
timeout: { hours: number; minutes: number };
|
||||
action: VaultTimeoutAction;
|
||||
}>;
|
||||
vaultTimeoutOptions: VaultTimeoutOption[] = [];
|
||||
hasVaultTimeoutPolicy = false;
|
||||
supportsBiometric: boolean;
|
||||
showChangeMasterPass = true;
|
||||
accountSwitcherEnabled = false;
|
||||
@@ -71,6 +109,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
vaultTimeout: [null as VaultTimeout | null],
|
||||
vaultTimeoutAction: [VaultTimeoutAction.Lock],
|
||||
pin: [null as boolean | null],
|
||||
pinLockWithMasterPassword: false,
|
||||
biometric: false,
|
||||
enableAutoBiometricsPrompt: true,
|
||||
});
|
||||
@@ -102,20 +141,12 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
const hasMasterPassword = await this.userVerificationService.hasMasterPassword();
|
||||
this.showMasterPasswordOnClientRestartOption = hasMasterPassword;
|
||||
const maximumVaultTimeoutPolicy = this.policyService.get$(PolicyType.MaximumVaultTimeout);
|
||||
this.vaultTimeoutPolicyCallout = maximumVaultTimeoutPolicy.pipe(
|
||||
filter((policy) => policy != null),
|
||||
map((policy) => {
|
||||
let timeout;
|
||||
if (policy.data?.minutes) {
|
||||
timeout = {
|
||||
hours: Math.floor(policy.data?.minutes / 60),
|
||||
minutes: policy.data?.minutes % 60,
|
||||
};
|
||||
}
|
||||
return { timeout: timeout, action: policy.data?.action };
|
||||
}),
|
||||
);
|
||||
if ((await firstValueFrom(this.policyService.get$(PolicyType.MaximumVaultTimeout))) != null) {
|
||||
this.hasVaultTimeoutPolicy = true;
|
||||
}
|
||||
|
||||
const showOnLocked =
|
||||
!this.platformUtilsService.isFirefox() && !this.platformUtilsService.isSafari();
|
||||
@@ -161,6 +192,8 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(activeAccount.id),
|
||||
),
|
||||
pin: await this.pinService.isPinSet(activeAccount.id),
|
||||
pinLockWithMasterPassword:
|
||||
(await this.pinService.getPinLockType(activeAccount.id)) == "EPHEMERAL",
|
||||
biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(),
|
||||
enableAutoBiometricsPrompt: await firstValueFrom(
|
||||
this.biometricStateService.promptAutomatically$,
|
||||
@@ -185,9 +218,8 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
this.form.controls.vaultTimeoutAction.valueChanges
|
||||
.pipe(
|
||||
startWith(initialValues.vaultTimeoutAction), // emit to init pairwise
|
||||
pairwise(),
|
||||
concatMap(async ([previousValue, newValue]) => {
|
||||
await this.saveVaultTimeoutAction(previousValue, newValue);
|
||||
map(async (value) => {
|
||||
await this.saveVaultTimeoutAction(value);
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
@@ -203,6 +235,22 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.form.controls.pinLockWithMasterPassword.valueChanges
|
||||
.pipe(
|
||||
concatMap(async (value) => {
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
|
||||
const pinKeyEncryptedUserKey =
|
||||
(await this.pinService.getPinKeyEncryptedUserKeyPersistent(userId)) ||
|
||||
(await this.pinService.getPinKeyEncryptedUserKeyEphemeral(userId));
|
||||
await this.pinService.clearPinKeyEncryptedUserKeyPersistent(userId);
|
||||
await this.pinService.clearPinKeyEncryptedUserKeyEphemeral(userId);
|
||||
await this.pinService.storePinKeyEncryptedUserKey(pinKeyEncryptedUserKey, value, userId);
|
||||
this.refreshTimeoutSettings$.next();
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.form.controls.biometric.valueChanges
|
||||
.pipe(
|
||||
distinctUntilChanged(),
|
||||
@@ -219,6 +267,15 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.form.controls.enableAutoBiometricsPrompt.valueChanges
|
||||
.pipe(
|
||||
concatMap(async (enabled) => {
|
||||
await this.biometricStateService.setPromptAutomatically(enabled);
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.refreshTimeoutSettings$
|
||||
.pipe(
|
||||
switchMap(() =>
|
||||
@@ -272,17 +329,6 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
// The minTimeoutError does not apply to browser because it supports Immediately
|
||||
// So only check for the policyError
|
||||
if (this.form.controls.vaultTimeout.hasError("policyError")) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: this.i18nService.t("vaultTimeoutTooLarge"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
|
||||
const vaultTimeoutAction = await firstValueFrom(
|
||||
@@ -299,8 +345,8 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async saveVaultTimeoutAction(previousValue: VaultTimeoutAction, newValue: VaultTimeoutAction) {
|
||||
if (newValue === VaultTimeoutAction.LogOut) {
|
||||
async saveVaultTimeoutAction(value: VaultTimeoutAction) {
|
||||
if (value === VaultTimeoutAction.LogOut) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "vaultTimeoutLogOutConfirmationTitle" },
|
||||
content: { key: "vaultTimeoutLogOutConfirmation" },
|
||||
@@ -308,7 +354,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
this.form.controls.vaultTimeoutAction.setValue(previousValue, {
|
||||
this.form.controls.vaultTimeoutAction.setValue(VaultTimeoutAction.Lock, {
|
||||
emitEvent: false,
|
||||
});
|
||||
return;
|
||||
@@ -329,7 +375,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
await this.vaultTimeoutSettingsService.setVaultTimeoutOptions(
|
||||
activeAccount.id,
|
||||
this.form.value.vaultTimeout,
|
||||
newValue,
|
||||
value,
|
||||
);
|
||||
this.refreshTimeoutSettings$.next();
|
||||
}
|
||||
@@ -343,8 +389,13 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((account) => account.id)),
|
||||
);
|
||||
const userHasPinSet = await firstValueFrom(dialogRef.closed);
|
||||
this.form.controls.pin.setValue(userHasPinSet, { emitEvent: false });
|
||||
const requireReprompt = (await this.pinService.getPinLockType(userId)) == "EPHEMERAL";
|
||||
this.form.controls.pinLockWithMasterPassword.setValue(requireReprompt, { emitEvent: false });
|
||||
} else {
|
||||
await this.vaultTimeoutSettingsService.clear();
|
||||
}
|
||||
@@ -386,77 +437,91 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
let awaitDesktopDialogRef: DialogRef<boolean, unknown> | undefined;
|
||||
let biometricsResponseReceived = false;
|
||||
|
||||
await this.cryptoService.refreshAdditionalKeys();
|
||||
|
||||
const waitForUserDialogPromise = async () => {
|
||||
// only show waiting dialog if we have waited for 200 msec to prevent double dialog
|
||||
// the os will respond instantly if the dialog shows successfully, and the desktop app will respond instantly if something is wrong
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
if (biometricsResponseReceived) {
|
||||
const successful = await this.trySetupBiometrics();
|
||||
this.form.controls.biometric.setValue(successful);
|
||||
if (!successful) {
|
||||
await this.biometricStateService.setBiometricUnlockEnabled(false);
|
||||
await this.biometricStateService.setFingerprintValidated(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async trySetupBiometrics(): Promise<boolean> {
|
||||
let awaitDesktopDialogRef: DialogRef<boolean, unknown> | undefined;
|
||||
let biometricsResponseReceived = false;
|
||||
let setupResult = false;
|
||||
|
||||
const waitForUserDialogPromise = async () => {
|
||||
// only show waiting dialog if we have waited for 500 msec to prevent double dialog
|
||||
// the os will respond instantly if the dialog shows successfully, and the desktop app will respond instantly if something is wrong
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
if (biometricsResponseReceived) {
|
||||
return;
|
||||
}
|
||||
|
||||
awaitDesktopDialogRef = AwaitDesktopDialogComponent.open(this.dialogService);
|
||||
await firstValueFrom(awaitDesktopDialogRef.closed);
|
||||
if (!biometricsResponseReceived) {
|
||||
setupResult = false;
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
const biometricsPromise = async () => {
|
||||
try {
|
||||
const result = await this.biometricsService.authenticateBiometric();
|
||||
|
||||
// prevent duplicate dialog
|
||||
biometricsResponseReceived = true;
|
||||
if (awaitDesktopDialogRef) {
|
||||
awaitDesktopDialogRef.close(result);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
this.platformUtilsService.showToast(
|
||||
"error",
|
||||
this.i18nService.t("errorEnableBiometricTitle"),
|
||||
this.i18nService.t("errorEnableBiometricDesc"),
|
||||
);
|
||||
}
|
||||
setupResult = true;
|
||||
} catch (e) {
|
||||
// prevent duplicate dialog
|
||||
biometricsResponseReceived = true;
|
||||
if (awaitDesktopDialogRef) {
|
||||
awaitDesktopDialogRef.close(true);
|
||||
}
|
||||
|
||||
if (e.message == "canceled") {
|
||||
setupResult = false;
|
||||
return;
|
||||
}
|
||||
|
||||
awaitDesktopDialogRef = AwaitDesktopDialogComponent.open(this.dialogService);
|
||||
const result = await firstValueFrom(awaitDesktopDialogRef.closed);
|
||||
if (result !== true) {
|
||||
this.form.controls.biometric.setValue(false);
|
||||
const error = BiometricErrors[e.message as BiometricErrorTypes];
|
||||
const shouldRetry = await this.dialogService.openSimpleDialog({
|
||||
title: { key: error.title },
|
||||
content: { key: error.description },
|
||||
acceptButtonText: { key: "retry" },
|
||||
cancelButtonText: null,
|
||||
type: "danger",
|
||||
});
|
||||
if (shouldRetry) {
|
||||
setupResult = await this.trySetupBiometrics();
|
||||
} else {
|
||||
setupResult = false;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const biometricsPromise = async () => {
|
||||
try {
|
||||
const result = await this.biometricsService.authenticateBiometric();
|
||||
|
||||
// prevent duplicate dialog
|
||||
biometricsResponseReceived = true;
|
||||
if (awaitDesktopDialogRef) {
|
||||
awaitDesktopDialogRef.close(true);
|
||||
}
|
||||
|
||||
this.form.controls.biometric.setValue(result);
|
||||
if (!result) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorEnableBiometricTitle"),
|
||||
message: this.i18nService.t("errorEnableBiometricDesc"),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// prevent duplicate dialog
|
||||
biometricsResponseReceived = true;
|
||||
if (awaitDesktopDialogRef) {
|
||||
awaitDesktopDialogRef.close(true);
|
||||
}
|
||||
|
||||
this.form.controls.biometric.setValue(false);
|
||||
|
||||
if (e.message == "canceled") {
|
||||
return;
|
||||
}
|
||||
|
||||
const error = BiometricErrors[e.message as BiometricErrorTypes];
|
||||
await this.dialogService.openSimpleDialog({
|
||||
title: { key: error.title },
|
||||
content: { key: error.description },
|
||||
acceptButtonText: { key: "ok" },
|
||||
cancelButtonText: null,
|
||||
type: "danger",
|
||||
});
|
||||
} finally {
|
||||
if (awaitDesktopDialogRef) {
|
||||
awaitDesktopDialogRef.close(true);
|
||||
}
|
||||
} finally {
|
||||
if (awaitDesktopDialogRef) {
|
||||
awaitDesktopDialogRef.close(true);
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.race([waitForUserDialogPromise(), biometricsPromise()]);
|
||||
} else {
|
||||
await this.biometricStateService.setBiometricUnlockEnabled(false);
|
||||
await this.biometricStateService.setFingerprintValidated(false);
|
||||
}
|
||||
await Promise.all([waitForUserDialogPromise(), biometricsPromise()]);
|
||||
return setupResult;
|
||||
}
|
||||
|
||||
async updateAutoBiometricsPrompt() {
|
||||
@@ -471,6 +536,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
content: { key: "changeMasterPasswordOnWebConfirmation" },
|
||||
type: "info",
|
||||
acceptButtonText: { key: "continue" },
|
||||
cancelButtonText: { key: "cancel" },
|
||||
});
|
||||
if (confirmed) {
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
@@ -480,9 +546,11 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
|
||||
async twoStep() {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "twoStepLogin" },
|
||||
content: { key: "twoStepLoginConfirmation" },
|
||||
title: { key: "twoStepLoginConfirmationTitle" },
|
||||
content: { key: "twoStepLoginConfirmationContent" },
|
||||
type: "info",
|
||||
acceptButtonText: { key: "continue" },
|
||||
cancelButtonText: { key: "cancel" },
|
||||
});
|
||||
if (confirmed) {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
|
||||
@@ -5,23 +5,17 @@ import {
|
||||
createAutofillPageDetailsMock,
|
||||
createAutofillScriptMock,
|
||||
} from "../spec/autofill-mocks";
|
||||
import { flushPromises, sendMockExtensionMessage } from "../spec/testing-utils";
|
||||
import {
|
||||
flushPromises,
|
||||
mockQuerySelectorAllDefinedCall,
|
||||
sendMockExtensionMessage,
|
||||
} from "../spec/testing-utils";
|
||||
import { FormFieldElement } from "../types";
|
||||
|
||||
let pageDetailsMock: AutofillPageDetails;
|
||||
let fillScriptMock: AutofillScript;
|
||||
let autofillFieldElementByOpidMock: FormFieldElement;
|
||||
|
||||
jest.mock("../services/dom-query.service", () => {
|
||||
const module = jest.requireActual("../services/dom-query.service");
|
||||
return {
|
||||
DomQueryService: class extends module.DomQueryService {
|
||||
deepQueryElements<T>(element: HTMLElement, queryString: string): T[] {
|
||||
return Array.from(element.querySelectorAll(queryString)) as T[];
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
jest.mock("../services/collect-autofill-content.service", () => {
|
||||
const module = jest.requireActual("../services/collect-autofill-content.service");
|
||||
return {
|
||||
@@ -47,6 +41,8 @@ jest.mock("../services/collect-autofill-content.service", () => {
|
||||
jest.mock("../services/insert-autofill-content.service");
|
||||
|
||||
describe("AutoSubmitLogin content script", () => {
|
||||
const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
setupEnvironmentDefaults();
|
||||
@@ -60,6 +56,7 @@ describe("AutoSubmitLogin content script", () => {
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
mockQuerySelectorAll.mockRestore();
|
||||
});
|
||||
|
||||
it("ends the auto-submit login workflow if the page does not contain any fields", async () => {
|
||||
|
||||
@@ -10,7 +10,9 @@ import InsertAutofillContentService from "../services/insert-autofill-content.se
|
||||
import {
|
||||
elementIsInputElement,
|
||||
getSubmitButtonKeywordsSet,
|
||||
nodeIsButtonElement,
|
||||
nodeIsFormElement,
|
||||
nodeIsTypeSubmitElement,
|
||||
sendExtensionMessage,
|
||||
} from "../utils";
|
||||
|
||||
@@ -189,13 +191,21 @@ import {
|
||||
element: HTMLElement,
|
||||
lastFieldIsPasswordInput = false,
|
||||
): boolean {
|
||||
const genericSubmitElement = querySubmitButtonElement(element, "[type='submit']");
|
||||
const genericSubmitElement = querySubmitButtonElement(
|
||||
element,
|
||||
"[type='submit']",
|
||||
(node: Node) => nodeIsTypeSubmitElement(node),
|
||||
);
|
||||
if (genericSubmitElement) {
|
||||
clickSubmitElement(genericSubmitElement, lastFieldIsPasswordInput);
|
||||
return true;
|
||||
}
|
||||
|
||||
const buttonElement = querySubmitButtonElement(element, "button, [type='button']");
|
||||
const buttonElement = querySubmitButtonElement(
|
||||
element,
|
||||
"button, [type='button']",
|
||||
(node: Node) => nodeIsButtonElement(node),
|
||||
);
|
||||
if (buttonElement) {
|
||||
clickSubmitElement(buttonElement, lastFieldIsPasswordInput);
|
||||
return true;
|
||||
@@ -210,11 +220,17 @@ import {
|
||||
*
|
||||
* @param element - The element to query for submit buttons
|
||||
* @param selector - The selector to query for submit buttons
|
||||
* @param treeWalkerFilter - The callback used to filter treeWalker results
|
||||
*/
|
||||
function querySubmitButtonElement(element: HTMLElement, selector: string) {
|
||||
const submitButtonElements = domQueryService.deepQueryElements<HTMLButtonElement>(
|
||||
function querySubmitButtonElement(
|
||||
element: HTMLElement,
|
||||
selector: string,
|
||||
treeWalkerFilter: CallableFunction,
|
||||
) {
|
||||
const submitButtonElements = domQueryService.query<HTMLButtonElement>(
|
||||
element,
|
||||
selector,
|
||||
treeWalkerFilter,
|
||||
);
|
||||
for (let index = 0; index < submitButtonElements.length; index++) {
|
||||
const submitElement = submitButtonElements[index];
|
||||
@@ -272,20 +288,11 @@ import {
|
||||
* Gets all form elements on the page.
|
||||
*/
|
||||
function getAutofillFormElements(): HTMLFormElement[] {
|
||||
const formElements: HTMLFormElement[] = [];
|
||||
domQueryService.queryAllTreeWalkerNodes(
|
||||
return domQueryService.query<HTMLFormElement>(
|
||||
globalContext.document.documentElement,
|
||||
(node: Node) => {
|
||||
if (nodeIsFormElement(node)) {
|
||||
formElements.push(node);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
"form",
|
||||
(node: Node) => nodeIsFormElement(node),
|
||||
);
|
||||
|
||||
return formElements;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,7 +38,7 @@ class AutofillInit implements AutofillInitInterface {
|
||||
* @param overlayNotificationsContentService - The overlay notifications content service, potentially undefined.
|
||||
*/
|
||||
constructor(
|
||||
private domQueryService: DomQueryService,
|
||||
domQueryService: DomQueryService,
|
||||
private autofillOverlayContentService?: AutofillOverlayContentService,
|
||||
private autofillInlineMenuContentService?: AutofillInlineMenuContentService,
|
||||
private overlayNotificationsContentService?: OverlayNotificationsContentService,
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
export interface DomQueryService {
|
||||
deepQueryElements<T>(
|
||||
query<T>(
|
||||
root: Document | ShadowRoot | Element,
|
||||
queryString: string,
|
||||
treeWalkerFilter: CallableFunction,
|
||||
mutationObserver?: MutationObserver,
|
||||
forceDeepQueryAttempt?: boolean,
|
||||
): T[];
|
||||
queryAllTreeWalkerNodes(
|
||||
rootNode: Node,
|
||||
filterCallback: CallableFunction,
|
||||
mutationObserver?: MutationObserver,
|
||||
): Node[];
|
||||
checkPageContainsShadowDom(): void;
|
||||
pageContainsShadowDomElements(): boolean;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
elementIsFillableFormField,
|
||||
elementIsSelectElement,
|
||||
getAttributeBoolean,
|
||||
nodeIsButtonElement,
|
||||
nodeIsTypeSubmitElement,
|
||||
sendExtensionMessage,
|
||||
throttle,
|
||||
} from "../utils";
|
||||
@@ -508,12 +510,20 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
|
||||
* @param element - The element to find the submit button within.
|
||||
*/
|
||||
private findSubmitButton(element: HTMLElement): HTMLElement | null {
|
||||
const genericSubmitElement = this.querySubmitButtonElement(element, "[type='submit']");
|
||||
const genericSubmitElement = this.querySubmitButtonElement(
|
||||
element,
|
||||
"[type='submit']",
|
||||
(node: Node) => nodeIsTypeSubmitElement(node),
|
||||
);
|
||||
if (genericSubmitElement) {
|
||||
return genericSubmitElement;
|
||||
}
|
||||
|
||||
const submitButtonElement = this.querySubmitButtonElement(element, "button, [type='button']");
|
||||
const submitButtonElement = this.querySubmitButtonElement(
|
||||
element,
|
||||
"button, [type='button']",
|
||||
(node: Node) => nodeIsButtonElement(node),
|
||||
);
|
||||
if (submitButtonElement) {
|
||||
return submitButtonElement;
|
||||
}
|
||||
@@ -524,11 +534,17 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
|
||||
*
|
||||
* @param element - The element to query for a submit button.
|
||||
* @param selector - The selector to use to query the element for a submit button.
|
||||
* @param treeWalkerFilter - The tree walker filter to use when querying the element.
|
||||
*/
|
||||
private querySubmitButtonElement(element: HTMLElement, selector: string) {
|
||||
const submitButtonElements = this.domQueryService.deepQueryElements<HTMLButtonElement>(
|
||||
private querySubmitButtonElement(
|
||||
element: HTMLElement,
|
||||
selector: string,
|
||||
treeWalkerFilter: CallableFunction,
|
||||
) {
|
||||
const submitButtonElements = this.domQueryService.query<HTMLButtonElement>(
|
||||
element,
|
||||
selector,
|
||||
treeWalkerFilter,
|
||||
);
|
||||
for (let index = 0; index < submitButtonElements.length; index++) {
|
||||
const submitElement = submitButtonElements[index];
|
||||
|
||||
@@ -17,6 +17,14 @@ import { CollectAutofillContentService } from "./collect-autofill-content.servic
|
||||
import DomElementVisibilityService from "./dom-element-visibility.service";
|
||||
import { DomQueryService } from "./dom-query.service";
|
||||
|
||||
jest.mock("../utils", () => {
|
||||
const utils = jest.requireActual("../utils");
|
||||
return {
|
||||
...utils,
|
||||
debounce: jest.fn((fn) => fn),
|
||||
};
|
||||
});
|
||||
|
||||
const mockLoginForm = `
|
||||
<div id="root">
|
||||
<form>
|
||||
@@ -29,6 +37,7 @@ const mockLoginForm = `
|
||||
const waitForIdleCallback = () => new Promise((resolve) => globalThis.requestIdleCallback(resolve));
|
||||
|
||||
describe("CollectAutofillContentService", () => {
|
||||
const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall();
|
||||
const domElementVisibilityService = new DomElementVisibilityService();
|
||||
const inlineMenuFieldQualificationService = mock<InlineMenuFieldQualificationService>();
|
||||
const domQueryService = new DomQueryService();
|
||||
@@ -38,7 +47,6 @@ describe("CollectAutofillContentService", () => {
|
||||
);
|
||||
let collectAutofillContentService: CollectAutofillContentService;
|
||||
const mockIntersectionObserver = mock<IntersectionObserver>();
|
||||
const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall();
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.requestIdleCallback = jest.fn((cb, options) => setTimeout(cb, 100));
|
||||
@@ -55,6 +63,7 @@ describe("CollectAutofillContentService", () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
jest.clearAllTimers();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
@@ -2001,41 +2010,6 @@ describe("CollectAutofillContentService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getShadowRoot", () => {
|
||||
beforeEach(() => {
|
||||
// eslint-disable-next-line
|
||||
// @ts-ignore
|
||||
globalThis.chrome.dom = {
|
||||
openOrClosedShadowRoot: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it("returns null if the passed node is not an HTMLElement instance", () => {
|
||||
const textNode = document.createTextNode("Hello, world!");
|
||||
const shadowRoot = collectAutofillContentService["getShadowRoot"](textNode);
|
||||
|
||||
expect(shadowRoot).toEqual(null);
|
||||
});
|
||||
|
||||
it("returns an open shadow root if the passed node has a shadowDOM element", () => {
|
||||
const element = document.createElement("div");
|
||||
element.attachShadow({ mode: "open" });
|
||||
|
||||
const shadowRoot = collectAutofillContentService["getShadowRoot"](element);
|
||||
|
||||
expect(shadowRoot).toBeInstanceOf(ShadowRoot);
|
||||
});
|
||||
|
||||
it("returns a value provided by Chrome's openOrClosedShadowRoot API", () => {
|
||||
const element = document.createElement("div");
|
||||
collectAutofillContentService["getShadowRoot"](element);
|
||||
|
||||
// eslint-disable-next-line
|
||||
// @ts-ignore
|
||||
expect(chrome.dom.openOrClosedShadowRoot).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setupMutationObserver", () => {
|
||||
it("sets up a mutation observer and observes the document element", () => {
|
||||
jest.spyOn(MutationObserver.prototype, "observe");
|
||||
@@ -2048,6 +2022,12 @@ describe("CollectAutofillContentService", () => {
|
||||
});
|
||||
|
||||
describe("handleMutationObserverMutation", () => {
|
||||
const waitForAllMutationsToComplete = async () => {
|
||||
await waitForIdleCallback();
|
||||
await waitForIdleCallback();
|
||||
await waitForIdleCallback();
|
||||
};
|
||||
|
||||
it("will set the domRecentlyMutated value to true and the noFieldsFound value to false if a form or field node has been added ", async () => {
|
||||
const form = document.createElement("form");
|
||||
document.body.appendChild(form);
|
||||
@@ -2071,7 +2051,7 @@ describe("CollectAutofillContentService", () => {
|
||||
jest.spyOn(collectAutofillContentService as any, "isAutofillElementNodeMutated");
|
||||
|
||||
collectAutofillContentService["handleMutationObserverMutation"]([mutationRecord]);
|
||||
await waitForIdleCallback();
|
||||
await waitForAllMutationsToComplete();
|
||||
|
||||
expect(collectAutofillContentService["domRecentlyMutated"]).toEqual(true);
|
||||
expect(collectAutofillContentService["noFieldsFound"]).toEqual(false);
|
||||
@@ -2115,7 +2095,7 @@ describe("CollectAutofillContentService", () => {
|
||||
target: document.body,
|
||||
},
|
||||
]);
|
||||
await waitForIdleCallback();
|
||||
await waitForAllMutationsToComplete();
|
||||
|
||||
expect(collectAutofillContentService["_autofillFormElements"].size).toEqual(0);
|
||||
expect(collectAutofillContentService["autofillFieldElements"].size).toEqual(0);
|
||||
@@ -2140,7 +2120,7 @@ describe("CollectAutofillContentService", () => {
|
||||
jest.spyOn(collectAutofillContentService as any, "handleAutofillElementAttributeMutation");
|
||||
|
||||
collectAutofillContentService["handleMutationObserverMutation"]([mutationRecord]);
|
||||
await waitForIdleCallback();
|
||||
await waitForAllMutationsToComplete();
|
||||
|
||||
expect(collectAutofillContentService["domRecentlyMutated"]).toEqual(false);
|
||||
expect(collectAutofillContentService["noFieldsFound"]).toEqual(true);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
getPropertyOrAttribute,
|
||||
requestIdleCallbackPolyfill,
|
||||
cancelIdleCallbackPolyfill,
|
||||
debounce,
|
||||
} from "../utils";
|
||||
|
||||
import { AutofillOverlayContentService } from "./abstractions/autofill-overlay-content.service";
|
||||
@@ -57,7 +58,6 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
"image",
|
||||
"file",
|
||||
]);
|
||||
private useTreeWalkerStrategyFlagSet = true;
|
||||
|
||||
constructor(
|
||||
private domElementVisibilityService: DomElementVisibilityService,
|
||||
@@ -69,11 +69,6 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
inputQuery += `:not([type="${type}"])`;
|
||||
}
|
||||
this.formFieldQueryString = `${inputQuery}, textarea:not([data-bwignore]), select:not([data-bwignore]), span[data-bwautofill]`;
|
||||
|
||||
// void sendExtensionMessage("getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag").then(
|
||||
// (useTreeWalkerStrategyFlag) =>
|
||||
// (this.useTreeWalkerStrategyFlagSet = !!useTreeWalkerStrategyFlag?.result),
|
||||
// );
|
||||
}
|
||||
|
||||
get autofillFormElements(): AutofillFormElements {
|
||||
@@ -297,13 +292,12 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
): FormFieldElement[] {
|
||||
let formFieldElements = previouslyFoundFormFieldElements;
|
||||
if (!formFieldElements) {
|
||||
formFieldElements = this.useTreeWalkerStrategyFlagSet
|
||||
? this.queryTreeWalkerForAutofillFormFieldElements()
|
||||
: this.domQueryService.deepQueryElements(
|
||||
document,
|
||||
this.formFieldQueryString,
|
||||
this.mutationObserver,
|
||||
);
|
||||
formFieldElements = this.domQueryService.query<FormFieldElement>(
|
||||
globalThis.document.documentElement,
|
||||
this.formFieldQueryString,
|
||||
(node: Node) => this.isNodeFormFieldElement(node),
|
||||
this.mutationObserver,
|
||||
);
|
||||
}
|
||||
|
||||
if (!fieldsLimit || formFieldElements.length <= fieldsLimit) {
|
||||
@@ -836,17 +830,32 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
formElements: HTMLFormElement[];
|
||||
formFieldElements: FormFieldElement[];
|
||||
} {
|
||||
if (this.useTreeWalkerStrategyFlagSet) {
|
||||
return this.queryTreeWalkerForAutofillFormAndFieldElements();
|
||||
}
|
||||
|
||||
const queriedElements = this.domQueryService.deepQueryElements<HTMLElement>(
|
||||
document,
|
||||
`form, ${this.formFieldQueryString}`,
|
||||
this.mutationObserver,
|
||||
);
|
||||
const formElements: HTMLFormElement[] = [];
|
||||
const formFieldElements: FormFieldElement[] = [];
|
||||
|
||||
const queriedElements = this.domQueryService.query<HTMLElement>(
|
||||
globalThis.document.documentElement,
|
||||
`form, ${this.formFieldQueryString}`,
|
||||
(node: Node) => {
|
||||
if (nodeIsFormElement(node)) {
|
||||
formElements.push(node);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.isNodeFormFieldElement(node)) {
|
||||
formFieldElements.push(node as FormFieldElement);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
this.mutationObserver,
|
||||
);
|
||||
|
||||
if (formElements.length || formFieldElements.length) {
|
||||
return { formElements, formFieldElements };
|
||||
}
|
||||
|
||||
for (let index = 0; index < queriedElements.length; index++) {
|
||||
const element = queriedElements[index];
|
||||
if (elementIsFormElement(element)) {
|
||||
@@ -891,34 +900,6 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
return this.nonInputFormFieldTags.has(nodeTagName) && !nodeHasBwIgnoreAttribute;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to get the ShadowRoot of the passed node. If support for the
|
||||
* extension based openOrClosedShadowRoot API is available, it will be used.
|
||||
* Will return null if the node is not an HTMLElement or if the node has
|
||||
* child nodes.
|
||||
*
|
||||
* @param {Node} node
|
||||
*/
|
||||
private getShadowRoot(node: Node): ShadowRoot | null {
|
||||
if (!nodeIsElement(node)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.shadowRoot) {
|
||||
return node.shadowRoot;
|
||||
}
|
||||
|
||||
if ((chrome as any).dom?.openOrClosedShadowRoot) {
|
||||
try {
|
||||
return (chrome as any).dom.openOrClosedShadowRoot(node);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (node as any).openOrClosedShadowRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a mutation observer on the body of the document. Observes changes to
|
||||
* DOM elements to ensure we have an updated set of autofill field data.
|
||||
@@ -948,7 +929,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
}
|
||||
|
||||
if (!this.mutationsQueue.length) {
|
||||
requestIdleCallbackPolyfill(this.processMutations, { timeout: 500 });
|
||||
requestIdleCallbackPolyfill(debounce(this.processMutations, 100), { timeout: 500 });
|
||||
}
|
||||
this.mutationsQueue.push(mutations);
|
||||
};
|
||||
@@ -979,41 +960,62 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
* within an idle callback to help with performance and prevent excessive updates.
|
||||
*/
|
||||
private processMutations = () => {
|
||||
for (let queueIndex = 0; queueIndex < this.mutationsQueue.length; queueIndex++) {
|
||||
this.processMutationRecord(this.mutationsQueue[queueIndex]);
|
||||
const queueLength = this.mutationsQueue.length;
|
||||
|
||||
if (!this.domQueryService.pageContainsShadowDomElements()) {
|
||||
this.domQueryService.checkPageContainsShadowDom();
|
||||
}
|
||||
|
||||
if (this.domRecentlyMutated) {
|
||||
this.updateAutofillElementsAfterMutation();
|
||||
for (let queueIndex = 0; queueIndex < queueLength; queueIndex++) {
|
||||
const mutations = this.mutationsQueue[queueIndex];
|
||||
const processMutationRecords = () => {
|
||||
this.processMutationRecords(mutations);
|
||||
|
||||
if (queueIndex === queueLength - 1 && this.domRecentlyMutated) {
|
||||
this.updateAutofillElementsAfterMutation();
|
||||
}
|
||||
};
|
||||
|
||||
requestIdleCallbackPolyfill(processMutationRecords, { timeout: 500 });
|
||||
}
|
||||
|
||||
this.mutationsQueue = [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Processes a mutation record and updates the autofill elements if necessary.
|
||||
* Processes all mutation records encountered by the mutation observer.
|
||||
*
|
||||
* @param mutations - The mutation record to process
|
||||
*/
|
||||
private processMutationRecord(mutations: MutationRecord[]) {
|
||||
private processMutationRecords(mutations: MutationRecord[]) {
|
||||
for (let mutationIndex = 0; mutationIndex < mutations.length; mutationIndex++) {
|
||||
const mutation = mutations[mutationIndex];
|
||||
if (
|
||||
mutation.type === "childList" &&
|
||||
(this.isAutofillElementNodeMutated(mutation.removedNodes, true) ||
|
||||
this.isAutofillElementNodeMutated(mutation.addedNodes))
|
||||
) {
|
||||
this.domRecentlyMutated = true;
|
||||
if (this.autofillOverlayContentService) {
|
||||
this.autofillOverlayContentService.pageDetailsUpdateRequired = true;
|
||||
}
|
||||
this.noFieldsFound = false;
|
||||
continue;
|
||||
}
|
||||
const mutation: MutationRecord = mutations[mutationIndex];
|
||||
const processMutationRecord = () => this.processMutationRecord(mutation);
|
||||
requestIdleCallbackPolyfill(processMutationRecord, { timeout: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
if (mutation.type === "attributes") {
|
||||
this.handleAutofillElementAttributeMutation(mutation);
|
||||
/**
|
||||
* Processes a single mutation record and updates the autofill elements if necessary.
|
||||
* @param mutation
|
||||
* @private
|
||||
*/
|
||||
private processMutationRecord(mutation: MutationRecord) {
|
||||
if (
|
||||
mutation.type === "childList" &&
|
||||
(this.isAutofillElementNodeMutated(mutation.removedNodes, true) ||
|
||||
this.isAutofillElementNodeMutated(mutation.addedNodes))
|
||||
) {
|
||||
this.domRecentlyMutated = true;
|
||||
if (this.autofillOverlayContentService) {
|
||||
this.autofillOverlayContentService.pageDetailsUpdateRequired = true;
|
||||
}
|
||||
this.noFieldsFound = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (mutation.type === "attributes") {
|
||||
this.handleAutofillElementAttributeMutation(mutation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1036,20 +1038,19 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
!this.useTreeWalkerStrategyFlagSet &&
|
||||
(nodeIsFormElement(node) || this.isNodeFormFieldElement(node))
|
||||
) {
|
||||
if (nodeIsFormElement(node) || this.isNodeFormFieldElement(node)) {
|
||||
mutatedElements.push(node as HTMLElement);
|
||||
}
|
||||
|
||||
const autofillElements = this.useTreeWalkerStrategyFlagSet
|
||||
? this.queryTreeWalkerForMutatedElements(node)
|
||||
: this.domQueryService.deepQueryElements<HTMLElement>(
|
||||
node,
|
||||
`form, ${this.formFieldQueryString}`,
|
||||
this.mutationObserver,
|
||||
);
|
||||
const autofillElements = this.domQueryService.query<HTMLElement>(
|
||||
node,
|
||||
`form, ${this.formFieldQueryString}`,
|
||||
(walkerNode: Node) =>
|
||||
nodeIsFormElement(walkerNode) || this.isNodeFormFieldElement(walkerNode),
|
||||
this.mutationObserver,
|
||||
true,
|
||||
);
|
||||
|
||||
if (autofillElements.length) {
|
||||
mutatedElements = mutatedElements.concat(autofillElements);
|
||||
}
|
||||
@@ -1083,19 +1084,20 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
private setupOverlayListenersOnMutatedElements(mutatedElements: Node[]) {
|
||||
for (let elementIndex = 0; elementIndex < mutatedElements.length; elementIndex++) {
|
||||
const node = mutatedElements[elementIndex];
|
||||
if (
|
||||
!this.isNodeFormFieldElement(node) ||
|
||||
this.autofillFieldElements.get(node as ElementWithOpId<FormFieldElement>)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const buildAutofillFieldItem = () => {
|
||||
if (
|
||||
!this.isNodeFormFieldElement(node) ||
|
||||
this.autofillFieldElements.get(node as ElementWithOpId<FormFieldElement>)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestIdleCallbackPolyfill(
|
||||
// We are setting this item to a -1 index because we do not know its position in the DOM.
|
||||
// This value should be updated with the next call to collect page details.
|
||||
() => void this.buildAutofillFieldItem(node as ElementWithOpId<FormFieldElement>, -1),
|
||||
{ timeout: 1000 },
|
||||
);
|
||||
void this.buildAutofillFieldItem(node as ElementWithOpId<FormFieldElement>, -1);
|
||||
};
|
||||
|
||||
requestIdleCallbackPolyfill(buildAutofillFieldItem, { timeout: 1000 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1367,6 +1369,19 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates whether a password field is within the document.
|
||||
*/
|
||||
isPasswordFieldWithinDocument(): boolean {
|
||||
return (
|
||||
this.domQueryService.query<HTMLInputElement>(
|
||||
globalThis.document.documentElement,
|
||||
`input[type="password"]`,
|
||||
(node: Node) => nodeIsInputElement(node) && node.type === "password",
|
||||
)?.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys the CollectAutofillContentService. Clears all
|
||||
* timeouts and disconnects the mutation observer.
|
||||
@@ -1378,84 +1393,4 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
this.mutationObserver?.disconnect();
|
||||
this.intersectionObserver?.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails.
|
||||
*/
|
||||
private queryTreeWalkerForAutofillFormAndFieldElements(): {
|
||||
formElements: HTMLFormElement[];
|
||||
formFieldElements: FormFieldElement[];
|
||||
} {
|
||||
const formElements: HTMLFormElement[] = [];
|
||||
const formFieldElements: FormFieldElement[] = [];
|
||||
this.domQueryService.queryAllTreeWalkerNodes(
|
||||
document.documentElement,
|
||||
(node: Node) => {
|
||||
if (nodeIsFormElement(node)) {
|
||||
formElements.push(node);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.isNodeFormFieldElement(node)) {
|
||||
formFieldElements.push(node as FormFieldElement);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
this.mutationObserver,
|
||||
);
|
||||
|
||||
return { formElements, formFieldElements };
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails.
|
||||
*/
|
||||
private queryTreeWalkerForAutofillFormFieldElements(): FormFieldElement[] {
|
||||
return this.domQueryService.queryAllTreeWalkerNodes(
|
||||
document.documentElement,
|
||||
(node: Node) => this.isNodeFormFieldElement(node),
|
||||
this.mutationObserver,
|
||||
) as FormFieldElement[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails.
|
||||
*
|
||||
* @param node - The node to query
|
||||
*/
|
||||
private queryTreeWalkerForMutatedElements(node: Node): HTMLElement[] {
|
||||
return this.domQueryService.queryAllTreeWalkerNodes(
|
||||
node,
|
||||
(walkerNode: Node) =>
|
||||
nodeIsFormElement(walkerNode) || this.isNodeFormFieldElement(walkerNode),
|
||||
this.mutationObserver,
|
||||
) as HTMLElement[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails.
|
||||
*/
|
||||
private queryTreeWalkerForPasswordElements(): HTMLElement[] {
|
||||
return this.domQueryService.queryAllTreeWalkerNodes(
|
||||
document.documentElement,
|
||||
(node: Node) => nodeIsInputElement(node) && node.type === "password",
|
||||
) as HTMLElement[];
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a temporary method to maintain a fallback strategy for the tree walker API
|
||||
*
|
||||
* @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails.
|
||||
*/
|
||||
isPasswordFieldWithinDocument(): boolean {
|
||||
if (this.useTreeWalkerStrategyFlagSet) {
|
||||
return Boolean(this.queryTreeWalkerForPasswordElements()?.length);
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
this.domQueryService.deepQueryElements(document, `input[type="password"]`)?.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,60 @@
|
||||
import { mockQuerySelectorAllDefinedCall } from "../spec/testing-utils";
|
||||
import { flushPromises, mockQuerySelectorAllDefinedCall } from "../spec/testing-utils";
|
||||
|
||||
import { DomQueryService } from "./dom-query.service";
|
||||
|
||||
jest.mock("../utils", () => {
|
||||
const actualUtils = jest.requireActual("../utils");
|
||||
return {
|
||||
...actualUtils,
|
||||
sendExtensionMessage: jest.fn((command, options) => {
|
||||
if (command === "getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag") {
|
||||
return Promise.resolve({ result: false });
|
||||
}
|
||||
|
||||
return chrome.runtime.sendMessage(Object.assign({ command }, options));
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("DomQueryService", () => {
|
||||
const originalDocumentReadyState = document.readyState;
|
||||
let domQueryService: DomQueryService;
|
||||
let mutationObserver: MutationObserver;
|
||||
const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall();
|
||||
|
||||
beforeEach(() => {
|
||||
domQueryService = new DomQueryService();
|
||||
beforeEach(async () => {
|
||||
mutationObserver = new MutationObserver(() => {});
|
||||
domQueryService = new DomQueryService();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(document, "readyState", {
|
||||
value: originalDocumentReadyState,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
mockQuerySelectorAll.mockRestore();
|
||||
});
|
||||
|
||||
it("checks the page content for shadow DOM elements after the page has completed loading", async () => {
|
||||
Object.defineProperty(document, "readyState", {
|
||||
value: "loading",
|
||||
writable: true,
|
||||
});
|
||||
jest.spyOn(globalThis, "addEventListener");
|
||||
|
||||
const domQueryService = new DomQueryService();
|
||||
await flushPromises();
|
||||
|
||||
expect(globalThis.addEventListener).toHaveBeenCalledWith(
|
||||
"load",
|
||||
domQueryService["checkPageContainsShadowDom"],
|
||||
);
|
||||
});
|
||||
|
||||
describe("deepQueryElements", () => {
|
||||
it("queries form field elements that are nested within a ShadowDOM", () => {
|
||||
const root = document.createElement("div");
|
||||
@@ -26,9 +65,10 @@ describe("DomQueryService", () => {
|
||||
form.appendChild(input);
|
||||
shadowRoot.appendChild(form);
|
||||
|
||||
const formFieldElements = domQueryService.deepQueryElements(
|
||||
const formFieldElements = domQueryService.query(
|
||||
shadowRoot,
|
||||
"input",
|
||||
(element: Element) => element.tagName === "INPUT",
|
||||
mutationObserver,
|
||||
);
|
||||
|
||||
@@ -36,6 +76,7 @@ describe("DomQueryService", () => {
|
||||
});
|
||||
|
||||
it("queries form field elements that are nested within multiple ShadowDOM elements", () => {
|
||||
domQueryService["pageContainsShadowDom"] = true;
|
||||
const root = document.createElement("div");
|
||||
const shadowRoot1 = root.attachShadow({ mode: "open" });
|
||||
const root2 = document.createElement("div");
|
||||
@@ -47,18 +88,50 @@ describe("DomQueryService", () => {
|
||||
shadowRoot2.appendChild(form);
|
||||
shadowRoot1.appendChild(root2);
|
||||
|
||||
const formFieldElements = domQueryService.deepQueryElements(
|
||||
const formFieldElements = domQueryService.query(
|
||||
shadowRoot1,
|
||||
"input",
|
||||
(element: Element) => element.tagName === "INPUT",
|
||||
mutationObserver,
|
||||
);
|
||||
|
||||
expect(formFieldElements).toStrictEqual([input]);
|
||||
});
|
||||
|
||||
it("will fallback to using the TreeWalker API if a depth larger than 4 ShadowDOM elements is encountered", () => {
|
||||
domQueryService["pageContainsShadowDom"] = true;
|
||||
const root = document.createElement("div");
|
||||
const shadowRoot1 = root.attachShadow({ mode: "open" });
|
||||
const root2 = document.createElement("div");
|
||||
const shadowRoot2 = root2.attachShadow({ mode: "open" });
|
||||
const root3 = document.createElement("div");
|
||||
const shadowRoot3 = root3.attachShadow({ mode: "open" });
|
||||
const root4 = document.createElement("div");
|
||||
const shadowRoot4 = root4.attachShadow({ mode: "open" });
|
||||
const root5 = document.createElement("div");
|
||||
const shadowRoot5 = root5.attachShadow({ mode: "open" });
|
||||
const form = document.createElement("form");
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
form.appendChild(input);
|
||||
shadowRoot5.appendChild(form);
|
||||
shadowRoot4.appendChild(root5);
|
||||
shadowRoot3.appendChild(root4);
|
||||
shadowRoot2.appendChild(root3);
|
||||
shadowRoot1.appendChild(root2);
|
||||
const treeWalkerCallback = jest
|
||||
.fn()
|
||||
.mockImplementation(() => (element: Element) => element.tagName === "INPUT");
|
||||
|
||||
domQueryService.query(shadowRoot1, "input", treeWalkerCallback, mutationObserver);
|
||||
|
||||
expect(treeWalkerCallback).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("queryAllTreeWalkerNodes", () => {
|
||||
it("queries form field elements that are nested within multiple ShadowDOM elements", () => {
|
||||
domQueryService["pageContainsShadowDom"] = true;
|
||||
const root = document.createElement("div");
|
||||
const shadowRoot1 = root.attachShadow({ mode: "open" });
|
||||
const root2 = document.createElement("div");
|
||||
@@ -70,8 +143,9 @@ describe("DomQueryService", () => {
|
||||
shadowRoot2.appendChild(form);
|
||||
shadowRoot1.appendChild(root2);
|
||||
|
||||
const formFieldElements = domQueryService.queryAllTreeWalkerNodes(
|
||||
const formFieldElements = domQueryService.query(
|
||||
shadowRoot1,
|
||||
"input",
|
||||
(element: Element) => element.tagName === "INPUT",
|
||||
mutationObserver,
|
||||
);
|
||||
|
||||
@@ -1,8 +1,77 @@
|
||||
import { nodeIsElement } from "../utils";
|
||||
import { EVENTS, MAX_DEEP_QUERY_RECURSION_DEPTH } from "@bitwarden/common/autofill/constants";
|
||||
|
||||
import { nodeIsElement, sendExtensionMessage } from "../utils";
|
||||
|
||||
import { DomQueryService as DomQueryServiceInterface } from "./abstractions/dom-query.service";
|
||||
|
||||
export class DomQueryService implements DomQueryServiceInterface {
|
||||
private pageContainsShadowDom: boolean;
|
||||
private useTreeWalkerStrategyFlagSet = true;
|
||||
|
||||
constructor() {
|
||||
void this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a query that will trigger a deepQuery of the DOM, querying all elements that match the given query string.
|
||||
* If the deepQuery fails or reaches a max recursion depth, it will fall back to a treeWalker query.
|
||||
*
|
||||
* @param root - The root element to start the query from
|
||||
* @param queryString - The query string to match elements against
|
||||
* @param treeWalkerFilter - The filter callback to use for the treeWalker query
|
||||
* @param mutationObserver - The MutationObserver to use for observing shadow roots
|
||||
* @param forceDeepQueryAttempt - Whether to force a deep query attempt
|
||||
*/
|
||||
query<T>(
|
||||
root: Document | ShadowRoot | Element,
|
||||
queryString: string,
|
||||
treeWalkerFilter: CallableFunction,
|
||||
mutationObserver?: MutationObserver,
|
||||
forceDeepQueryAttempt?: boolean,
|
||||
): T[] {
|
||||
if (!forceDeepQueryAttempt && this.pageContainsShadowDomElements()) {
|
||||
return this.queryAllTreeWalkerNodes<T>(root, treeWalkerFilter, mutationObserver);
|
||||
}
|
||||
|
||||
try {
|
||||
return this.deepQueryElements<T>(root, queryString, mutationObserver);
|
||||
} catch {
|
||||
return this.queryAllTreeWalkerNodes<T>(root, treeWalkerFilter, mutationObserver);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the page contains any shadow DOM elements.
|
||||
*/
|
||||
checkPageContainsShadowDom = (): void => {
|
||||
this.pageContainsShadowDom = this.queryShadowRoots(globalThis.document.body, true).length > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines whether to use the treeWalker strategy for querying the DOM.
|
||||
*/
|
||||
pageContainsShadowDomElements(): boolean {
|
||||
return this.useTreeWalkerStrategyFlagSet || this.pageContainsShadowDom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the DomQueryService, checking for the presence of shadow DOM elements on the page.
|
||||
*/
|
||||
private async init() {
|
||||
const useTreeWalkerStrategyFlag = await sendExtensionMessage(
|
||||
"getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag",
|
||||
);
|
||||
if (useTreeWalkerStrategyFlag && typeof useTreeWalkerStrategyFlag.result === "boolean") {
|
||||
this.useTreeWalkerStrategyFlagSet = useTreeWalkerStrategyFlag.result;
|
||||
}
|
||||
|
||||
if (globalThis.document.readyState === "complete") {
|
||||
this.checkPageContainsShadowDom();
|
||||
return;
|
||||
}
|
||||
globalThis.addEventListener(EVENTS.LOAD, this.checkPageContainsShadowDom);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries all elements in the DOM that match the given query string.
|
||||
* Also, recursively queries all shadow roots for the element.
|
||||
@@ -11,16 +80,25 @@ export class DomQueryService implements DomQueryServiceInterface {
|
||||
* @param queryString - The query string to match elements against
|
||||
* @param mutationObserver - The MutationObserver to use for observing shadow roots
|
||||
*/
|
||||
deepQueryElements<T>(
|
||||
private deepQueryElements<T>(
|
||||
root: Document | ShadowRoot | Element,
|
||||
queryString: string,
|
||||
mutationObserver?: MutationObserver,
|
||||
): T[] {
|
||||
let elements = this.queryElements<T>(root, queryString);
|
||||
const shadowRoots = this.recursivelyQueryShadowRoots(root, mutationObserver);
|
||||
|
||||
const shadowRoots = this.recursivelyQueryShadowRoots(root);
|
||||
for (let index = 0; index < shadowRoots.length; index++) {
|
||||
const shadowRoot = shadowRoots[index];
|
||||
elements = elements.concat(this.queryElements<T>(shadowRoot, queryString));
|
||||
|
||||
if (mutationObserver) {
|
||||
mutationObserver.observe(shadowRoot, {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return elements;
|
||||
@@ -46,23 +124,24 @@ export class DomQueryService implements DomQueryServiceInterface {
|
||||
* `isObservingShadowRoot` parameter is set to true.
|
||||
*
|
||||
* @param root - The root element to start the query from
|
||||
* @param mutationObserver - The MutationObserver to use for observing shadow roots
|
||||
* @param depth - The depth of the recursion
|
||||
*/
|
||||
private recursivelyQueryShadowRoots(
|
||||
root: Document | ShadowRoot | Element,
|
||||
mutationObserver?: MutationObserver,
|
||||
depth: number = 0,
|
||||
): ShadowRoot[] {
|
||||
if (!this.pageContainsShadowDom) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (depth >= MAX_DEEP_QUERY_RECURSION_DEPTH) {
|
||||
throw new Error("Max recursion depth reached");
|
||||
}
|
||||
|
||||
let shadowRoots = this.queryShadowRoots(root);
|
||||
for (let index = 0; index < shadowRoots.length; index++) {
|
||||
const shadowRoot = shadowRoots[index];
|
||||
shadowRoots = shadowRoots.concat(this.recursivelyQueryShadowRoots(shadowRoot));
|
||||
if (mutationObserver) {
|
||||
mutationObserver.observe(shadowRoot, {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
shadowRoots = shadowRoots.concat(this.recursivelyQueryShadowRoots(shadowRoot, depth + 1));
|
||||
}
|
||||
|
||||
return shadowRoots;
|
||||
@@ -72,14 +151,23 @@ export class DomQueryService implements DomQueryServiceInterface {
|
||||
* Queries any immediate shadow roots found within the given root element.
|
||||
*
|
||||
* @param root - The root element to start the query from
|
||||
* @param returnSingleShadowRoot - Whether to return a single shadow root or an array of shadow roots
|
||||
*/
|
||||
private queryShadowRoots(root: Document | ShadowRoot | Element): ShadowRoot[] {
|
||||
private queryShadowRoots(
|
||||
root: Document | ShadowRoot | Element,
|
||||
returnSingleShadowRoot = false,
|
||||
): ShadowRoot[] {
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
const potentialShadowRoots = root.querySelectorAll(":defined");
|
||||
for (let index = 0; index < potentialShadowRoots.length; index++) {
|
||||
const shadowRoot = this.getShadowRoot(potentialShadowRoots[index]);
|
||||
if (shadowRoot) {
|
||||
shadowRoots.push(shadowRoot);
|
||||
if (!shadowRoot) {
|
||||
continue;
|
||||
}
|
||||
|
||||
shadowRoots.push(shadowRoot);
|
||||
if (returnSingleShadowRoot) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,12 +209,12 @@ export class DomQueryService implements DomQueryServiceInterface {
|
||||
* @param filterCallback
|
||||
* @param mutationObserver
|
||||
*/
|
||||
queryAllTreeWalkerNodes(
|
||||
private queryAllTreeWalkerNodes<T>(
|
||||
rootNode: Node,
|
||||
filterCallback: CallableFunction,
|
||||
mutationObserver?: MutationObserver,
|
||||
): Node[] {
|
||||
const treeWalkerQueryResults: Node[] = [];
|
||||
): T[] {
|
||||
const treeWalkerQueryResults: T[] = [];
|
||||
|
||||
this.buildTreeWalkerNodesQueryResults(
|
||||
rootNode,
|
||||
@@ -147,9 +235,9 @@ export class DomQueryService implements DomQueryServiceInterface {
|
||||
* @param filterCallback
|
||||
* @param mutationObserver
|
||||
*/
|
||||
private buildTreeWalkerNodesQueryResults(
|
||||
private buildTreeWalkerNodesQueryResults<T>(
|
||||
rootNode: Node,
|
||||
treeWalkerQueryResults: Node[],
|
||||
treeWalkerQueryResults: T[],
|
||||
filterCallback: CallableFunction,
|
||||
mutationObserver?: MutationObserver,
|
||||
) {
|
||||
@@ -158,7 +246,7 @@ export class DomQueryService implements DomQueryServiceInterface {
|
||||
|
||||
while (currentNode) {
|
||||
if (filterCallback(currentNode)) {
|
||||
treeWalkerQueryResults.push(currentNode);
|
||||
treeWalkerQueryResults.push(currentNode as T);
|
||||
}
|
||||
|
||||
const nodeShadowRoot = this.getShadowRoot(currentNode);
|
||||
|
||||
@@ -68,6 +68,7 @@ function setMockWindowLocation({
|
||||
}
|
||||
|
||||
describe("InsertAutofillContentService", () => {
|
||||
const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall();
|
||||
const inlineMenuFieldQualificationService = mock<InlineMenuFieldQualificationService>();
|
||||
const domQueryService = new DomQueryService();
|
||||
const domElementVisibilityService = new DomElementVisibilityService();
|
||||
@@ -82,7 +83,6 @@ describe("InsertAutofillContentService", () => {
|
||||
);
|
||||
let insertAutofillContentService: InsertAutofillContentService;
|
||||
let fillScript: AutofillScript;
|
||||
const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall();
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = mockLoginForm;
|
||||
|
||||
@@ -176,7 +176,7 @@ export function triggerWebRequestOnCompletedEvent(details: chrome.webRequest.Web
|
||||
|
||||
export function mockQuerySelectorAllDefinedCall() {
|
||||
const originalDocumentQuerySelectorAll = document.querySelectorAll;
|
||||
document.querySelectorAll = function (selector: string) {
|
||||
globalThis.document.querySelectorAll = function (selector: string) {
|
||||
return originalDocumentQuerySelectorAll.call(
|
||||
document,
|
||||
selector === ":defined" ? "*" : selector,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
setElementStyles,
|
||||
setupExtensionDisconnectAction,
|
||||
setupAutofillInitDisconnectAction,
|
||||
debounce,
|
||||
} from "./index";
|
||||
|
||||
describe("buildSvgDomElement", () => {
|
||||
@@ -211,3 +212,35 @@ describe("setupAutofillInitDisconnectAction", () => {
|
||||
expect(window.bitwardenAutofillInit).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("debounce", () => {
|
||||
const debouncedFunction = jest.fn();
|
||||
const debounced = debounce(debouncedFunction, 100);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllTimers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not call the method until the delay is complete", () => {
|
||||
debounced();
|
||||
jest.advanceTimersByTime(50);
|
||||
expect(debouncedFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls the method a single time when the debounce is triggered multiple times", () => {
|
||||
debounced();
|
||||
debounced();
|
||||
debounced();
|
||||
jest.advanceTimersByTime(100);
|
||||
|
||||
expect(debouncedFunction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -311,6 +311,18 @@ export function nodeIsFormElement(node: Node): node is HTMLFormElement {
|
||||
return nodeIsElement(node) && elementIsFormElement(node);
|
||||
}
|
||||
|
||||
export function nodeIsTypeSubmitElement(node: Node): node is HTMLElement {
|
||||
return nodeIsElement(node) && getPropertyOrAttribute(node as HTMLElement, "type") === "submit";
|
||||
}
|
||||
|
||||
export function nodeIsButtonElement(node: Node): node is HTMLButtonElement {
|
||||
return (
|
||||
nodeIsElement(node) &&
|
||||
(elementIsInstanceOf<HTMLButtonElement>(node, "button") ||
|
||||
getPropertyOrAttribute(node as HTMLElement, "type") === "button")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a boolean representing the attribute value of an element.
|
||||
*
|
||||
@@ -361,6 +373,20 @@ export function throttle(callback: (_args: any) => any, limit: number) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounces a callback function to run after a delay of `delay` milliseconds.
|
||||
*
|
||||
* @param callback - The callback function to debounce.
|
||||
* @param delay - The time in milliseconds to debounce the callback.
|
||||
*/
|
||||
export function debounce(callback: (_args: any) => any, delay: number) {
|
||||
let timeout: NodeJS.Timeout;
|
||||
return function (...args: unknown[]) {
|
||||
globalThis.clearTimeout(timeout);
|
||||
timeout = globalThis.setTimeout(() => callback.apply(this, args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gathers and normalizes keywords from a potential submit button element. Used
|
||||
* to verify if the element submits a login or change password form.
|
||||
|
||||
@@ -41,6 +41,7 @@ import { LoginComponent } from "../auth/popup/login.component";
|
||||
import { RegisterComponent } from "../auth/popup/register.component";
|
||||
import { RemovePasswordComponent } from "../auth/popup/remove-password.component";
|
||||
import { SetPasswordComponent } from "../auth/popup/set-password.component";
|
||||
import { AccountSecurityComponent as AccountSecurityV1Component } from "../auth/popup/settings/account-security-v1.component";
|
||||
import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component";
|
||||
import { SsoComponent } from "../auth/popup/sso.component";
|
||||
import { TwoFactorAuthComponent } from "../auth/popup/two-factor-auth.component";
|
||||
@@ -296,12 +297,11 @@ const routes: Routes = [
|
||||
canActivate: [authGuard],
|
||||
data: { state: "autofill" },
|
||||
}),
|
||||
{
|
||||
...extensionRefreshSwap(AccountSecurityV1Component, AccountSecurityComponent, {
|
||||
path: "account-security",
|
||||
component: AccountSecurityComponent,
|
||||
canActivate: [authGuard],
|
||||
data: { state: "account-security" },
|
||||
},
|
||||
}),
|
||||
...extensionRefreshSwap(NotificationsSettingsV1Component, NotificationsSettingsComponent, {
|
||||
path: "notifications",
|
||||
canActivate: [authGuard],
|
||||
|
||||
@@ -15,7 +15,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { ColorPasswordCountPipe } from "@bitwarden/angular/pipes/color-password-count.pipe";
|
||||
import { ColorPasswordPipe } from "@bitwarden/angular/pipes/color-password.pipe";
|
||||
import { UserVerificationDialogComponent } from "@bitwarden/auth/angular";
|
||||
import { AvatarModule, ButtonModule, ToastModule } from "@bitwarden/components";
|
||||
import { AvatarModule, ButtonModule, FormFieldModule, ToastModule } from "@bitwarden/components";
|
||||
|
||||
import { AccountComponent } from "../auth/popup/account-switching/account.component";
|
||||
import { CurrentAccountComponent } from "../auth/popup/account-switching/current-account.component";
|
||||
@@ -30,6 +30,7 @@ import { LoginComponent } from "../auth/popup/login.component";
|
||||
import { RegisterComponent } from "../auth/popup/register.component";
|
||||
import { RemovePasswordComponent } from "../auth/popup/remove-password.component";
|
||||
import { SetPasswordComponent } from "../auth/popup/set-password.component";
|
||||
import { AccountSecurityComponent as AccountSecurityComponentV1 } from "../auth/popup/settings/account-security-v1.component";
|
||||
import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component";
|
||||
import { VaultTimeoutInputComponent } from "../auth/popup/settings/vault-timeout-input.component";
|
||||
import { SsoComponent } from "../auth/popup/sso.component";
|
||||
@@ -98,6 +99,7 @@ import "../platform/popup/locales";
|
||||
A11yModule,
|
||||
AppRoutingModule,
|
||||
AutofillComponent,
|
||||
AccountSecurityComponent,
|
||||
ToastModule.forRoot({
|
||||
maxOpened: 2,
|
||||
autoDismiss: true,
|
||||
@@ -132,6 +134,7 @@ import "../platform/popup/locales";
|
||||
HeaderComponent,
|
||||
UserVerificationDialogComponent,
|
||||
CurrentAccountComponent,
|
||||
FormFieldModule,
|
||||
ExtensionAnonLayoutWrapperComponent,
|
||||
],
|
||||
declarations: [
|
||||
@@ -171,7 +174,6 @@ import "../platform/popup/locales";
|
||||
SendListComponent,
|
||||
SendTypeComponent,
|
||||
SetPasswordComponent,
|
||||
AccountSecurityComponent,
|
||||
SettingsComponent,
|
||||
VaultSettingsComponent,
|
||||
ShareComponent,
|
||||
@@ -183,6 +185,7 @@ import "../platform/popup/locales";
|
||||
TwoFactorOptionsComponent,
|
||||
UpdateTempPasswordComponent,
|
||||
UserVerificationComponent,
|
||||
AccountSecurityComponentV1,
|
||||
VaultTimeoutInputComponent,
|
||||
ViewComponent,
|
||||
ViewCustomFieldsComponent,
|
||||
|
||||
@@ -88,12 +88,9 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
|
||||
return
|
||||
case "biometricUnlock":
|
||||
|
||||
var error: NSError?
|
||||
let laContext = LAContext()
|
||||
|
||||
laContext.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
|
||||
|
||||
if let e = error, e.code != kLAErrorBiometryLockout {
|
||||
if(!laContext.isBiometricsAvailable()){
|
||||
response.userInfo = [
|
||||
SFExtensionMessageKey: [
|
||||
"message": [
|
||||
@@ -162,6 +159,20 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
|
||||
}
|
||||
|
||||
return
|
||||
case "biometricUnlockAvailable":
|
||||
let laContext = LAContext()
|
||||
var isAvailable = laContext.isBiometricsAvailable();
|
||||
|
||||
response.userInfo = [
|
||||
SFExtensionMessageKey: [
|
||||
"message": [
|
||||
"command": "biometricUnlockAvailable",
|
||||
"response": isAvailable ? "available" : "not available",
|
||||
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
|
||||
],
|
||||
],
|
||||
]
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
@@ -194,6 +205,20 @@ func jsonDeserialize<T: Decodable>(json: String?) -> T? {
|
||||
}
|
||||
}
|
||||
|
||||
extension LAContext {
|
||||
func isBiometricsAvailable() -> Bool {
|
||||
var error: NSError?
|
||||
|
||||
self.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
|
||||
|
||||
if let e = error, e.code != kLAErrorBiometryLockout {
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DownloadFileMessage: Decodable, Encodable {
|
||||
var fileName: String
|
||||
var blobData: String?
|
||||
|
||||
@@ -3,6 +3,8 @@ import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
@@ -44,12 +46,14 @@ describe("AddEditV2Component", () => {
|
||||
const disable = jest.fn();
|
||||
const navigate = jest.fn();
|
||||
const back = jest.fn().mockResolvedValue(null);
|
||||
const collect = jest.fn().mockResolvedValue(null);
|
||||
|
||||
beforeEach(async () => {
|
||||
buildConfig.mockClear();
|
||||
disable.mockClear();
|
||||
navigate.mockClear();
|
||||
back.mockClear();
|
||||
collect.mockClear();
|
||||
|
||||
addEditCipherInfo$ = new BehaviorSubject(null);
|
||||
cipherServiceMock = mock<CipherService>();
|
||||
@@ -66,6 +70,7 @@ describe("AddEditV2Component", () => {
|
||||
{ provide: ActivatedRoute, useValue: { queryParams: queryParams$ } },
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
{ provide: CipherService, useValue: cipherServiceMock },
|
||||
{ provide: EventCollectionService, useValue: { collect } },
|
||||
],
|
||||
})
|
||||
.overrideProvider(CipherFormConfigService, {
|
||||
@@ -122,6 +127,57 @@ describe("AddEditV2Component", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("analytics", () => {
|
||||
it("does not log viewed event when mode is add", fakeAsync(() => {
|
||||
queryParams$.next({});
|
||||
|
||||
tick();
|
||||
|
||||
expect(collect).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it("does not log viewed event whe mode is clone", fakeAsync(() => {
|
||||
queryParams$.next({ cipherId: "222-333-444-5555", clone: "true" });
|
||||
buildConfigResponse.originalCipher = {} as Cipher;
|
||||
|
||||
tick();
|
||||
|
||||
expect(collect).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it("logs viewed event when mode is edit", fakeAsync(() => {
|
||||
buildConfigResponse.originalCipher = {
|
||||
edit: true,
|
||||
id: "222-333-444-5555",
|
||||
organizationId: "444-555-666",
|
||||
} as Cipher;
|
||||
queryParams$.next({ cipherId: "222-333-444-5555" });
|
||||
|
||||
tick();
|
||||
|
||||
expect(collect).toHaveBeenCalledWith(
|
||||
EventType.Cipher_ClientViewed,
|
||||
"222-333-444-5555",
|
||||
false,
|
||||
"444-555-666",
|
||||
);
|
||||
}));
|
||||
|
||||
it("logs viewed event whe mode is partial-edit", fakeAsync(() => {
|
||||
buildConfigResponse.originalCipher = { edit: false } as Cipher;
|
||||
queryParams$.next({ cipherId: "222-333-444-5555", orgId: "444-555-666" });
|
||||
|
||||
tick();
|
||||
|
||||
expect(collect).toHaveBeenCalledWith(
|
||||
EventType.Cipher_ClientViewed,
|
||||
"222-333-444-5555",
|
||||
false,
|
||||
"444-555-666",
|
||||
);
|
||||
}));
|
||||
});
|
||||
|
||||
describe("addEditCipherInfo initialization", () => {
|
||||
it("populates config.initialValues with `addEditCipherInfo` values", fakeAsync(() => {
|
||||
const addEditCipherInfo = {
|
||||
|
||||
@@ -6,6 +6,8 @@ import { ActivatedRoute, Params, Router } from "@angular/router";
|
||||
import { firstValueFrom, map, switchMap } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
@@ -160,6 +162,7 @@ export class AddEditV2Component implements OnInit {
|
||||
private popupRouterCacheService: PopupRouterCacheService,
|
||||
private router: Router,
|
||||
private cipherService: CipherService,
|
||||
private eventCollectionService: EventCollectionService,
|
||||
) {
|
||||
this.subscribeToParams();
|
||||
}
|
||||
@@ -275,6 +278,15 @@ export class AddEditV2Component implements OnInit {
|
||||
await this.cipherService.setAddEditCipherInfo(null);
|
||||
}
|
||||
|
||||
if (["edit", "partial-edit"].includes(config.mode) && config.originalCipher?.id) {
|
||||
await this.eventCollectionService.collect(
|
||||
EventType.Cipher_ClientViewed,
|
||||
config.originalCipher.id,
|
||||
false,
|
||||
config.originalCipher.organizationId,
|
||||
);
|
||||
}
|
||||
|
||||
return config;
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3,7 +3,9 @@ import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
@@ -29,10 +31,12 @@ describe("ViewV2Component", () => {
|
||||
let fixture: ComponentFixture<ViewV2Component>;
|
||||
const params$ = new Subject();
|
||||
const mockNavigate = jest.fn();
|
||||
const collect = jest.fn().mockResolvedValue(null);
|
||||
|
||||
const mockCipher = {
|
||||
id: "122-333-444",
|
||||
type: CipherType.Login,
|
||||
orgId: "222-444-555",
|
||||
};
|
||||
|
||||
const mockVaultPopupAutofillService = {
|
||||
@@ -48,6 +52,7 @@ describe("ViewV2Component", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
mockNavigate.mockClear();
|
||||
collect.mockClear();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ViewV2Component],
|
||||
@@ -59,6 +64,7 @@ describe("ViewV2Component", () => {
|
||||
{ provide: ConfigService, useValue: mock<ConfigService>() },
|
||||
{ provide: PopupRouterCacheService, useValue: mock<PopupRouterCacheService>() },
|
||||
{ provide: ActivatedRoute, useValue: { queryParams: params$ } },
|
||||
{ provide: EventCollectionService, useValue: { collect } },
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: {
|
||||
@@ -122,5 +128,18 @@ describe("ViewV2Component", () => {
|
||||
|
||||
expect(component.headerText).toEqual("viewItemHeader note");
|
||||
}));
|
||||
|
||||
it("sends viewed event", fakeAsync(() => {
|
||||
params$.next({ cipherId: "122-333-444" });
|
||||
|
||||
flush(); // Resolve all promises
|
||||
|
||||
expect(collect).toHaveBeenCalledWith(
|
||||
EventType.Cipher_ClientViewed,
|
||||
mockCipher.id,
|
||||
false,
|
||||
undefined,
|
||||
);
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,9 +6,11 @@ import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { firstValueFrom, map, Observable, switchMap } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AUTOFILL_ID, SHOW_AUTOFILL_BUTTON } from "@bitwarden/common/autofill/constants";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
@@ -73,6 +75,7 @@ export class ViewV2Component {
|
||||
private toastService: ToastService,
|
||||
private vaultPopupAutofillService: VaultPopupAutofillService,
|
||||
private accountService: AccountService,
|
||||
private eventCollectionService: EventCollectionService,
|
||||
) {
|
||||
this.subscribeToParams();
|
||||
}
|
||||
@@ -90,6 +93,13 @@ export class ViewV2Component {
|
||||
if (this.loadAction === AUTOFILL_ID || this.loadAction === SHOW_AUTOFILL_BUTTON) {
|
||||
await this.vaultPopupAutofillService.doAutofill(this.cipher);
|
||||
}
|
||||
|
||||
await this.eventCollectionService.collect(
|
||||
EventType.Cipher_ClientViewed,
|
||||
cipher.id,
|
||||
false,
|
||||
cipher.organizationId,
|
||||
);
|
||||
}),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
@@ -152,7 +162,8 @@ export class ViewV2Component {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.router.navigate(["/vault"]);
|
||||
const successRoute = this.cipher.isDeleted ? "/trash" : "/vault";
|
||||
await this.router.navigate([successRoute]);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
@@ -169,7 +180,7 @@ export class ViewV2Component {
|
||||
this.logService.error(e);
|
||||
}
|
||||
|
||||
await this.router.navigate(["/vault"]);
|
||||
await this.router.navigate(["/trash"]);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
|
||||
@@ -58,7 +58,7 @@ export class TrashListItemsContainerComponent {
|
||||
try {
|
||||
await this.cipherService.restoreWithServer(cipher.id);
|
||||
|
||||
await this.router.navigate(["/vault"]);
|
||||
await this.router.navigate(["/trash"]);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
@@ -89,7 +89,7 @@ export class TrashListItemsContainerComponent {
|
||||
try {
|
||||
await this.cipherService.deleteWithServer(cipher.id);
|
||||
|
||||
await this.router.navigate(["/vault"]);
|
||||
await this.router.navigate(["/trash"]);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
"papaparse": "5.4.1",
|
||||
"proper-lockfile": "4.1.2",
|
||||
"rxjs": "7.8.1",
|
||||
"tldts": "6.1.41",
|
||||
"tldts": "6.1.46",
|
||||
"zxcvbn": "4.4.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,8 +37,14 @@ fn clipboard_set(set: Set, _password: bool) -> Set {
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn clipboard_set(set: Set, _password: bool) -> Set {
|
||||
set
|
||||
fn clipboard_set(set: Set, password: bool) -> Set {
|
||||
use arboard::SetExtApple;
|
||||
|
||||
if password {
|
||||
set.exclude_from_history()
|
||||
} else {
|
||||
set
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.16.4",
|
||||
"@types/node": "20.16.5",
|
||||
"@types/node-ipc": "9.2.3",
|
||||
"typescript": "4.7.4"
|
||||
}
|
||||
@@ -106,9 +106,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.16.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.4.tgz",
|
||||
"integrity": "sha512-ioyQ1zK9aGEomJ45zz8S8IdzElyxhvP1RVWnPrXDf6wFaUb+kk1tEcVVJkF7RPGM0VWI7cp5U57oCPIn5iN1qg==",
|
||||
"version": "20.16.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.5.tgz",
|
||||
"integrity": "sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.19.2"
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.16.4",
|
||||
"@types/node": "20.16.5",
|
||||
"@types/node-ipc": "9.2.3",
|
||||
"typescript": "4.7.4"
|
||||
},
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
bitCheckbox
|
||||
formControlName="requireMasterPasswordOnClientRestart"
|
||||
/>
|
||||
<span>{{ "lockWithMasterPassOnRestart" | i18n }}</span>
|
||||
<span>{{ "lockWithMasterPassOnRestart1" | i18n }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div bitDialogFooter>
|
||||
|
||||
@@ -942,6 +942,9 @@
|
||||
"vaultTimeout": {
|
||||
"message": "Vault timeout"
|
||||
},
|
||||
"vaultTimeout1": {
|
||||
"message": "Timeout"
|
||||
},
|
||||
"vaultTimeoutDesc": {
|
||||
"message": "Choose when your vault will take the vault timeout action."
|
||||
},
|
||||
@@ -1567,7 +1570,7 @@
|
||||
"recommendedForSecurity": {
|
||||
"message": "Recommended for security."
|
||||
},
|
||||
"lockWithMasterPassOnRestart": {
|
||||
"lockWithMasterPassOnRestart1": {
|
||||
"message": "Lock with master password on restart"
|
||||
},
|
||||
"deleteAccount": {
|
||||
@@ -2099,8 +2102,8 @@
|
||||
"minutes": {
|
||||
"message": "Minutes"
|
||||
},
|
||||
"vaultTimeoutPolicyInEffect": {
|
||||
"message": "Your organization policies have set your maximum allowed vault timeout to $HOURS$ hour(s) and $MINUTES$ minute(s).",
|
||||
"vaultTimeoutPolicyInEffect1": {
|
||||
"message": "$HOURS$ hour(s) and $MINUTES$ minute(s) maximum.",
|
||||
"placeholders": {
|
||||
"hours": {
|
||||
"content": "$1",
|
||||
|
||||
@@ -53,10 +53,19 @@
|
||||
[class]="'tw-grid-cols-' + selectableProducts.length"
|
||||
>
|
||||
<div
|
||||
*ngFor="let selectableProduct of selectableProducts; let i = index"
|
||||
*ngFor="
|
||||
let selectableProduct of selectableProducts;
|
||||
trackBy: manageSelectableProduct;
|
||||
let i = index
|
||||
"
|
||||
[ngClass]="getPlanCardContainerClasses(selectableProduct, i)"
|
||||
(click)="selectPlan(selectableProduct)"
|
||||
tabindex="0"
|
||||
[attr.tabindex]="focusedIndex !== i || isCardDisabled(i) ? '-1' : '0'"
|
||||
class="product-card"
|
||||
(keyup)="onKeydown($event, i)"
|
||||
(focus)="onFocus(i)"
|
||||
[attr.aria-disabled]="isCardDisabled(i)"
|
||||
[id]="i + 'a_plan_card'"
|
||||
>
|
||||
<div class="tw-relative">
|
||||
<div
|
||||
|
||||
@@ -160,6 +160,9 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
showPayment: boolean = false;
|
||||
totalOpened: boolean = false;
|
||||
currentPlan: PlanResponse;
|
||||
currentFocusIndex = 0;
|
||||
isCardStateDisabled = false;
|
||||
focusedIndex: number | null = null;
|
||||
|
||||
deprecateStripeSourcesAPI: boolean;
|
||||
|
||||
@@ -255,6 +258,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
setInitialPlanSelection() {
|
||||
this.focusedIndex = this.selectableProducts.length - 1;
|
||||
this.selectPlan(this.getPlanByType(ProductTierType.Enterprise));
|
||||
}
|
||||
|
||||
@@ -307,15 +311,22 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
|
||||
if (plan == this.currentPlan) {
|
||||
cardState = PlanCardState.Disabled;
|
||||
this.isCardStateDisabled = true;
|
||||
this.focusedIndex = index;
|
||||
} else if (plan == this.selectedPlan) {
|
||||
cardState = PlanCardState.Selected;
|
||||
this.isCardStateDisabled = false;
|
||||
this.focusedIndex = index;
|
||||
} else if (
|
||||
this.selectedInterval === PlanInterval.Monthly &&
|
||||
plan.productTier == ProductTierType.Families
|
||||
) {
|
||||
cardState = PlanCardState.Disabled;
|
||||
this.isCardStateDisabled = true;
|
||||
this.focusedIndex = this.selectableProducts.length - 1;
|
||||
} else {
|
||||
cardState = PlanCardState.NotSelected;
|
||||
this.isCardStateDisabled = false;
|
||||
}
|
||||
|
||||
switch (cardState) {
|
||||
@@ -466,7 +477,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
get storageGb() {
|
||||
return this.sub?.maxStorageGb - 1;
|
||||
return this.sub?.maxStorageGb ? this.sub?.maxStorageGb - 1 : 0;
|
||||
}
|
||||
|
||||
passwordManagerSeatTotal(plan: PlanResponse): number {
|
||||
@@ -492,7 +503,8 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
return (
|
||||
plan.PasswordManager.additionalStoragePricePerGb * Math.abs(this.sub?.maxStorageGb - 1 || 0)
|
||||
plan.PasswordManager.additionalStoragePricePerGb *
|
||||
Math.abs(this.sub?.maxStorageGb ? this.sub?.maxStorageGb - 1 : 0 || 0)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -861,4 +873,44 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
return this.i18nService.t("planNameTeams");
|
||||
}
|
||||
}
|
||||
|
||||
onKeydown(event: KeyboardEvent, index: number) {
|
||||
const cardElements = Array.from(document.querySelectorAll(".product-card")) as HTMLElement[];
|
||||
let newIndex = index;
|
||||
const direction = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1;
|
||||
|
||||
if (["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp"].includes(event.key)) {
|
||||
do {
|
||||
newIndex = (newIndex + direction + cardElements.length) % cardElements.length;
|
||||
} while (this.isCardDisabled(newIndex) && newIndex !== index);
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
setTimeout(() => {
|
||||
const card = cardElements[newIndex];
|
||||
if (
|
||||
!(
|
||||
card.classList.contains("tw-bg-secondary-100") &&
|
||||
card.classList.contains("tw-text-muted")
|
||||
)
|
||||
) {
|
||||
card?.focus();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
onFocus(index: number) {
|
||||
this.focusedIndex = index;
|
||||
this.selectPlan(this.selectableProducts[index]);
|
||||
}
|
||||
|
||||
isCardDisabled(index: number): boolean {
|
||||
const card = this.selectableProducts[index];
|
||||
return card === (this.currentPlan || this.isCardStateDisabled);
|
||||
}
|
||||
|
||||
manageSelectableProduct(index: number) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<button type="submit" bitButton bitFormButton buttonType="primary" [bitAction]="submit">
|
||||
{{ "submit" }}
|
||||
{{ "submit" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -49,7 +49,7 @@ export class VaultHeaderComponent implements OnInit {
|
||||
protected All = All;
|
||||
protected CollectionDialogTabType = CollectionDialogTabType;
|
||||
protected CipherType = CipherType;
|
||||
protected extensionRefreshEnabled = false;
|
||||
protected extensionRefreshEnabled: boolean;
|
||||
|
||||
/**
|
||||
* Boolean to determine the loading state of the header.
|
||||
|
||||
@@ -1024,8 +1024,8 @@
|
||||
"unexpectedError": {
|
||||
"message": "An unexpected error has occurred."
|
||||
},
|
||||
"expirationDateError" : {
|
||||
"message":"Please select an expiration date that is in the future."
|
||||
"expirationDateError": {
|
||||
"message": "Please select an expiration date that is in the future."
|
||||
},
|
||||
"emailAddress": {
|
||||
"message": "Email address"
|
||||
@@ -1033,8 +1033,8 @@
|
||||
"yourVaultIsLockedV2": {
|
||||
"message": "Your vault is locked"
|
||||
},
|
||||
"uuid":{
|
||||
"message" : "UUID"
|
||||
"uuid": {
|
||||
"message": "UUID"
|
||||
},
|
||||
"unlock": {
|
||||
"message": "Unlock"
|
||||
@@ -1270,10 +1270,10 @@
|
||||
"copyUuid": {
|
||||
"message": "Copy UUID"
|
||||
},
|
||||
"errorRefreshingAccessToken":{
|
||||
"errorRefreshingAccessToken": {
|
||||
"message": "Access Token Refresh Error"
|
||||
},
|
||||
"errorRefreshingAccessTokenDesc":{
|
||||
"errorRefreshingAccessTokenDesc": {
|
||||
"message": "No refresh token or API keys found. Please try logging out and logging back in."
|
||||
},
|
||||
"warning": {
|
||||
@@ -3993,6 +3993,9 @@
|
||||
"vaultTimeout": {
|
||||
"message": "Vault timeout"
|
||||
},
|
||||
"vaultTimeout1": {
|
||||
"message": "Timeout"
|
||||
},
|
||||
"vaultTimeoutDesc": {
|
||||
"message": "Choose when your vault will take the vault timeout action."
|
||||
},
|
||||
@@ -4997,7 +5000,7 @@
|
||||
"youNeedApprovalFromYourAdminToTrySecretsManager": {
|
||||
"message": "You need approval from your administrator to try Secrets Manager."
|
||||
},
|
||||
"smAccessRequestEmailSent" : {
|
||||
"smAccessRequestEmailSent": {
|
||||
"message": "Access request for secrets manager email sent to admins."
|
||||
},
|
||||
"requestAccessSMDefaultEmailContent": {
|
||||
@@ -5006,8 +5009,8 @@
|
||||
"giveMembersAccess": {
|
||||
"message": "Give members access:"
|
||||
},
|
||||
"viewAndSelectTheMembers" : {
|
||||
"message" :"view and select the members you want to give access to Secrets Manager."
|
||||
"viewAndSelectTheMembers": {
|
||||
"message": "view and select the members you want to give access to Secrets Manager."
|
||||
},
|
||||
"openYourOrganizations": {
|
||||
"message": "Open your organization's"
|
||||
@@ -5471,6 +5474,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"vaultTimeoutPolicyInEffect1": {
|
||||
"message": "$HOURS$ hour(s) and $MINUTES$ minute(s) maximum.",
|
||||
"placeholders": {
|
||||
"hours": {
|
||||
"content": "$1",
|
||||
"example": "5"
|
||||
},
|
||||
"minutes": {
|
||||
"content": "$2",
|
||||
"example": "5"
|
||||
}
|
||||
}
|
||||
},
|
||||
"vaultTimeoutPolicyWithActionInEffect": {
|
||||
"message": "Your organization policies are affecting your vault timeout. Maximum allowed vault timeout is $HOURS$ hour(s) and $MINUTES$ minute(s). Your vault timeout action is set to $ACTION$.",
|
||||
"placeholders": {
|
||||
@@ -5497,9 +5513,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"customVaultTimeout": {
|
||||
"message": "Custom vault timeout"
|
||||
},
|
||||
"vaultTimeoutToLarge": {
|
||||
"message": "Your vault timeout exceeds the restriction set by your organization."
|
||||
},
|
||||
@@ -5944,10 +5957,10 @@
|
||||
"selfHostedBaseUrlHint": {
|
||||
"message": "Specify the base URL of your on-premises hosted Bitwarden installation. Example: https://bitwarden.company.com"
|
||||
},
|
||||
"selfHostedCustomEnvHeader" :{
|
||||
"selfHostedCustomEnvHeader": {
|
||||
"message": "For advanced configuration, you can specify the base URL of each service independently."
|
||||
},
|
||||
"selfHostedEnvFormInvalid" :{
|
||||
"selfHostedEnvFormInvalid": {
|
||||
"message": "You must add either the base Server URL or at least one custom environment."
|
||||
},
|
||||
"apiUrl": {
|
||||
@@ -7709,7 +7722,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"verificationRequired" : {
|
||||
"verificationRequired": {
|
||||
"message": "Verification required",
|
||||
"description": "Default title for the user verification dialog."
|
||||
},
|
||||
@@ -8501,7 +8514,7 @@
|
||||
"deleteProviderRecoverConfirmDesc": {
|
||||
"message": "You have requested to delete this Provider. Use the button below to confirm."
|
||||
},
|
||||
"deleteProviderWarning": {
|
||||
"deleteProviderWarning": {
|
||||
"message": "Deleting your provider is permanent. It cannot be undone."
|
||||
},
|
||||
"errorAssigningTargetCollection": {
|
||||
@@ -8514,7 +8527,7 @@
|
||||
"message": "Integrations & SDKs",
|
||||
"description": "The title for the section that deals with integrations and SDKs."
|
||||
},
|
||||
"integrations":{
|
||||
"integrations": {
|
||||
"message": "Integrations"
|
||||
},
|
||||
"integrationsDesc": {
|
||||
@@ -8585,7 +8598,7 @@
|
||||
},
|
||||
"createdNewClient": {
|
||||
"message": "Successfully created new client"
|
||||
},
|
||||
},
|
||||
"noAccess": {
|
||||
"message": "No access"
|
||||
},
|
||||
@@ -8821,11 +8834,11 @@
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"content": "$1",
|
||||
"example":"increments of 100,000"
|
||||
"example": "increments of 100,000"
|
||||
}
|
||||
}
|
||||
},
|
||||
"providerReinstate":{
|
||||
"providerReinstate": {
|
||||
"message": " Contact Customer Support to reinstate your subscription."
|
||||
},
|
||||
"secretPeopleDescription": {
|
||||
@@ -9062,7 +9075,7 @@
|
||||
"message": "Directory integration"
|
||||
},
|
||||
"passwordLessSso": {
|
||||
"message": "PasswordLess SSO"
|
||||
"message": "Passwordless SSO"
|
||||
},
|
||||
"accountRecovery": {
|
||||
"message": "Account recovery"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<bit-simple-dialog>
|
||||
<i bitDialogIcon class="bwi bwi-info-circle tw-text-3xl" aria-hidden="true"></i>
|
||||
<span bitDialogTitle>{{ "yourAccountsFingerprint" | i18n }}:</span>
|
||||
<span bitDialogTitle
|
||||
><strong>{{ "yourAccountsFingerprint" | i18n }}:</strong></span
|
||||
>
|
||||
<span bitDialogContent>
|
||||
<strong>{{ data.fingerprint.join("-") }}</strong>
|
||||
{{ data.fingerprint.join("-") }}
|
||||
</span>
|
||||
<ng-container bitDialogFooter>
|
||||
<a
|
||||
|
||||
@@ -1,29 +1,47 @@
|
||||
<div [formGroup]="form">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "vaultTimeout" | i18n }}</bit-label>
|
||||
<div [formGroup]="form" class="tw-mb-4">
|
||||
<bit-form-field [disableMargin]="!showCustom">
|
||||
<bit-label>{{ "vaultTimeout1" | i18n }}</bit-label>
|
||||
<bit-select formControlName="vaultTimeout">
|
||||
<bit-option
|
||||
*ngFor="let o of vaultTimeoutOptions"
|
||||
*ngFor="let o of filteredVaultTimeoutOptions"
|
||||
[value]="o.value"
|
||||
[label]="o.name"
|
||||
></bit-option>
|
||||
</bit-select>
|
||||
<bit-hint class="tw-text-sm">{{
|
||||
((canLockVault$ | async) ? "vaultTimeoutDesc" : "vaultTimeoutLogoutDesc") | i18n
|
||||
}}</bit-hint>
|
||||
</bit-form-field>
|
||||
<div class="tw-grid tw-grid-cols-12 tw-gap-4" *ngIf="showCustom" formGroupName="custom">
|
||||
<bit-form-field class="tw-col-span-6">
|
||||
<bit-label>{{ "customVaultTimeout" | i18n }}</bit-label>
|
||||
<input bitInput type="number" min="0" formControlName="hours" />
|
||||
<bit-hint>{{ "hours" | i18n }}</bit-hint>
|
||||
<bit-form-field class="tw-col-span-6" disableMargin>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
min="0"
|
||||
formControlName="hours"
|
||||
aria-labelledby="maximum-error"
|
||||
/>
|
||||
<bit-label>{{ "hours" | i18n }}</bit-label>
|
||||
</bit-form-field>
|
||||
<bit-form-field class="tw-col-span-6 tw-self-end">
|
||||
<input bitInput type="number" min="0" name="minutes" formControlName="minutes" />
|
||||
<bit-hint>{{ "minutes" | i18n }}</bit-hint>
|
||||
<bit-form-field class="tw-col-span-6 tw-self-end" disableMargin>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
min="0"
|
||||
name="minutes"
|
||||
formControlName="minutes"
|
||||
aria-labelledby="maximum-error"
|
||||
/>
|
||||
<bit-label>{{ "minutes" | i18n }}</bit-label>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<small *ngIf="!exceedsMinimumTimout" class="tw-text-danger">
|
||||
<bit-hint *ngIf="vaultTimeoutPolicy != null && !exceedsMaximumTimeout">
|
||||
{{ "vaultTimeoutPolicyInEffect1" | i18n: vaultTimeoutPolicyHours : vaultTimeoutPolicyMinutes }}
|
||||
</bit-hint>
|
||||
<small *ngIf="!exceedsMinimumTimeout" class="tw-text-danger">
|
||||
<i class="bwi bwi-error" aria-hidden="true"></i> {{ "vaultCustomTimeoutMinimum" | i18n }}
|
||||
</small>
|
||||
<small class="tw-text-danger" *ngIf="exceedsMaximumTimeout" id="maximum-error">
|
||||
<i class="bwi bwi-error" aria-hidden="true"></i>
|
||||
{{
|
||||
"vaultTimeoutPolicyMaximumError" | i18n: vaultTimeoutPolicyHours : vaultTimeoutPolicyMinutes
|
||||
}}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
@@ -55,16 +55,41 @@ type VaultTimeoutFormValue = VaultTimeoutForm["value"];
|
||||
export class VaultTimeoutInputComponent
|
||||
implements ControlValueAccessor, Validator, OnInit, OnDestroy, OnChanges
|
||||
{
|
||||
protected readonly VaultTimeoutAction = VaultTimeoutAction;
|
||||
|
||||
get showCustom() {
|
||||
return this.form.get("vaultTimeout").value === VaultTimeoutInputComponent.CUSTOM_VALUE;
|
||||
}
|
||||
|
||||
get exceedsMinimumTimout(): boolean {
|
||||
get exceedsMinimumTimeout(): boolean {
|
||||
return (
|
||||
!this.showCustom || this.customTimeInMinutes() > VaultTimeoutInputComponent.MIN_CUSTOM_MINUTES
|
||||
);
|
||||
}
|
||||
|
||||
get exceedsMaximumTimeout(): boolean {
|
||||
return (
|
||||
this.showCustom &&
|
||||
this.customTimeInMinutes() >
|
||||
this.vaultTimeoutPolicyMinutes + 60 * this.vaultTimeoutPolicyHours
|
||||
);
|
||||
}
|
||||
|
||||
get filteredVaultTimeoutOptions(): VaultTimeoutOption[] {
|
||||
// by policy max value
|
||||
if (this.vaultTimeoutPolicy == null || this.vaultTimeoutPolicy.data == null) {
|
||||
return this.vaultTimeoutOptions;
|
||||
}
|
||||
|
||||
return this.vaultTimeoutOptions.filter((option) => {
|
||||
if (typeof option.value === "number") {
|
||||
return option.value <= this.vaultTimeoutPolicy.data.minutes;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
static CUSTOM_VALUE = -100;
|
||||
static MIN_CUSTOM_MINUTES = 0;
|
||||
|
||||
@@ -77,6 +102,7 @@ export class VaultTimeoutInputComponent
|
||||
});
|
||||
|
||||
@Input() vaultTimeoutOptions: VaultTimeoutOption[];
|
||||
|
||||
vaultTimeoutPolicy: Policy;
|
||||
vaultTimeoutPolicyHours: number;
|
||||
vaultTimeoutPolicyMinutes: number;
|
||||
@@ -207,7 +233,7 @@ export class VaultTimeoutInputComponent
|
||||
return { policyError: true };
|
||||
}
|
||||
|
||||
if (!this.exceedsMinimumTimout) {
|
||||
if (!this.exceedsMinimumTimeout) {
|
||||
return { minTimeoutError: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -107,3 +107,5 @@ export const ExtensionCommand = {
|
||||
export type ExtensionCommandType = (typeof ExtensionCommand)[keyof typeof ExtensionCommand];
|
||||
|
||||
export const CLEAR_NOTIFICATION_LOGIN_DATA_DURATION = 60 * 1000; // 1 minute
|
||||
|
||||
export const MAX_DEEP_QUERY_RECURSION_DEPTH = 4;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
bitSuffix
|
||||
bitPasswordInputToggle
|
||||
data-testid="visibility-for-card-number"
|
||||
(toggledChange)="logCardEvent($event, EventType.Cipher_ClientToggledCardNumberVisible)"
|
||||
></button>
|
||||
</bit-form-field>
|
||||
|
||||
@@ -60,6 +61,7 @@
|
||||
bitSuffix
|
||||
bitPasswordInputToggle
|
||||
data-testid="visibility-for-card-code"
|
||||
(toggledChange)="logCardEvent($event, EventType.Cipher_ClientToggledCardCodeVisible)"
|
||||
></button>
|
||||
</bit-form-field>
|
||||
</bit-card>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ReactiveFormsModule } from "@angular/forms";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
@@ -27,6 +28,7 @@ describe("CardDetailsSectionComponent", () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CardDetailsSectionComponent, CommonModule, ReactiveFormsModule],
|
||||
providers: [
|
||||
{ provide: EventCollectionService, useValue: mock<EventCollectionService>() },
|
||||
{ provide: CipherFormContainer, useValue: cipherFormProvider },
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
],
|
||||
|
||||
@@ -4,6 +4,8 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
@@ -91,10 +93,13 @@ export class CardDetailsSectionComponent implements OnInit {
|
||||
{ name: "12 - " + this.i18nService.t("december"), value: "12" },
|
||||
];
|
||||
|
||||
EventType = EventType;
|
||||
|
||||
constructor(
|
||||
private cipherFormContainer: CipherFormContainer,
|
||||
private formBuilder: FormBuilder,
|
||||
private i18nService: I18nService,
|
||||
private eventCollectionService: EventCollectionService,
|
||||
) {
|
||||
this.cipherFormContainer.registerChildForm("cardDetails", this.cardDetailsForm);
|
||||
|
||||
@@ -149,6 +154,21 @@ export class CardDetailsSectionComponent implements OnInit {
|
||||
return this.i18nService.t("cardDetails");
|
||||
}
|
||||
|
||||
async logCardEvent(hiddenFieldVisible: boolean, event: EventType) {
|
||||
const { mode, originalCipher } = this.cipherFormContainer.config;
|
||||
|
||||
const isEdit = ["edit", "partial-edit"].includes(mode);
|
||||
|
||||
if (hiddenFieldVisible && isEdit) {
|
||||
await this.eventCollectionService.collect(
|
||||
event,
|
||||
originalCipher.id,
|
||||
false,
|
||||
originalCipher.organizationId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Set form initial form values from the current cipher */
|
||||
private setInitialValues() {
|
||||
const { cardholderName, number, brand, expMonth, expYear, code } = this.originalCipherView.card;
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
bitPasswordInputToggle
|
||||
data-testid="visibility-for-custom-hidden-field"
|
||||
[disabled]="!canViewPasswords(i)"
|
||||
(toggledChange)="logHiddenEvent($event)"
|
||||
></button>
|
||||
</bit-form-field>
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ import { CdkDragDrop } from "@angular/cdk/drag-drop";
|
||||
import { DebugElement } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import {
|
||||
CardLinkedId,
|
||||
@@ -50,6 +52,7 @@ describe("CustomFieldsComponent", () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CustomFieldsComponent],
|
||||
providers: [
|
||||
{ provide: EventCollectionService, useValue: mock<EventCollectionService>() },
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: { t: (...keys: string[]) => keys.filter(Boolean).join(" ") },
|
||||
|
||||
@@ -19,6 +19,8 @@ import { FormArray, FormBuilder, FormsModule, ReactiveFormsModule } from "@angul
|
||||
import { Subject, zip } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherType, FieldType, LinkedIdType } from "@bitwarden/common/vault/enums";
|
||||
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
||||
@@ -118,6 +120,7 @@ export class CustomFieldsComponent implements OnInit, AfterViewInit {
|
||||
private formBuilder: FormBuilder,
|
||||
private i18nService: I18nService,
|
||||
private liveAnnouncer: LiveAnnouncer,
|
||||
private eventCollectionService: EventCollectionService,
|
||||
) {
|
||||
this.destroyed$ = inject(DestroyRef);
|
||||
this.cipherFormContainer.registerChildForm("customFields", this.customFieldsForm);
|
||||
@@ -301,6 +304,21 @@ export class CustomFieldsComponent implements OnInit, AfterViewInit {
|
||||
}
|
||||
}
|
||||
|
||||
async logHiddenEvent(hiddenFieldVisible: boolean) {
|
||||
const { mode, originalCipher } = this.cipherFormContainer.config;
|
||||
|
||||
const isEdit = ["edit", "partial-edit"].includes(mode);
|
||||
|
||||
if (hiddenFieldVisible && isEdit) {
|
||||
await this.eventCollectionService.collect(
|
||||
EventType.Cipher_ClientToggledHiddenFieldVisible,
|
||||
originalCipher.id,
|
||||
false,
|
||||
originalCipher.organizationId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the linked field options for the current cipher type
|
||||
*
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
*ngIf="viewHiddenFields"
|
||||
data-testid="toggle-password-visibility"
|
||||
bitPasswordInputToggle
|
||||
(toggledChange)="logVisibleEvent($event, EventType.Cipher_ClientToggledPasswordVisible)"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -113,6 +114,7 @@
|
||||
*ngIf="viewHiddenFields"
|
||||
data-testid="toggle-totp-visibility"
|
||||
bitPasswordInputToggle
|
||||
(toggledChange)="logVisibleEvent($event, EventType.Cipher_ClientToggledTOTPSeedVisible)"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { DatePipe } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { Fido2CredentialView } from "@bitwarden/common/vault/models/view/fido2-credential.view";
|
||||
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { BitPasswordInputToggleDirective } from "@bitwarden/components/src/form-field/password-input-toggle.directive";
|
||||
|
||||
import { CipherFormGenerationService } from "../../abstractions/cipher-form-generation.service";
|
||||
import { TotpCaptureService } from "../../abstractions/totp-capture.service";
|
||||
@@ -34,6 +39,7 @@ describe("LoginDetailsSectionComponent", () => {
|
||||
let toastService: MockProxy<ToastService>;
|
||||
let totpCaptureService: MockProxy<TotpCaptureService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
const collect = jest.fn().mockResolvedValue(null);
|
||||
|
||||
beforeEach(async () => {
|
||||
cipherFormContainer = mock<CipherFormContainer>();
|
||||
@@ -43,6 +49,7 @@ describe("LoginDetailsSectionComponent", () => {
|
||||
toastService = mock<ToastService>();
|
||||
totpCaptureService = mock<TotpCaptureService>();
|
||||
i18nService = mock<I18nService>();
|
||||
collect.mockClear();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [LoginDetailsSectionComponent],
|
||||
@@ -53,6 +60,7 @@ describe("LoginDetailsSectionComponent", () => {
|
||||
{ provide: ToastService, useValue: toastService },
|
||||
{ provide: TotpCaptureService, useValue: totpCaptureService },
|
||||
{ provide: I18nService, useValue: i18nService },
|
||||
{ provide: EventCollectionService, useValue: { collect } },
|
||||
],
|
||||
})
|
||||
.overrideComponent(LoginDetailsSectionComponent, {
|
||||
@@ -255,6 +263,32 @@ describe("LoginDetailsSectionComponent", () => {
|
||||
expect(getTogglePasswordVisibilityBtn()).toBeNull();
|
||||
});
|
||||
|
||||
it("logs password viewed event when toggledChange is true", async () => {
|
||||
cipherFormContainer.config.mode = "edit";
|
||||
cipherFormContainer.config.originalCipher = {
|
||||
id: "111-222-333",
|
||||
organizationId: "333-444-555",
|
||||
} as Cipher;
|
||||
jest.spyOn(component, "viewHiddenFields", "get").mockReturnValue(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const passwordToggle = fixture.debugElement.query(
|
||||
By.directive(BitPasswordInputToggleDirective),
|
||||
);
|
||||
await passwordToggle.triggerEventHandler("toggledChange", true);
|
||||
|
||||
expect(collect).toHaveBeenCalledWith(
|
||||
EventType.Cipher_ClientToggledPasswordVisible,
|
||||
"111-222-333",
|
||||
false,
|
||||
"333-444-555",
|
||||
);
|
||||
|
||||
await passwordToggle.triggerEventHandler("toggledChange", false);
|
||||
|
||||
expect(collect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe("password generation", () => {
|
||||
it("should show generate password button when editable", () => {
|
||||
expect(getGeneratePasswordBtn()).not.toBeNull();
|
||||
|
||||
@@ -6,6 +6,8 @@ import { map } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Fido2CredentialView } from "@bitwarden/common/vault/models/view/fido2-credential.view";
|
||||
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
|
||||
@@ -48,6 +50,7 @@ import { AutofillOptionsComponent } from "../autofill-options/autofill-options.c
|
||||
],
|
||||
})
|
||||
export class LoginDetailsSectionComponent implements OnInit {
|
||||
EventType = EventType;
|
||||
loginDetailsForm = this.formBuilder.group({
|
||||
username: [""],
|
||||
password: [""],
|
||||
@@ -106,6 +109,7 @@ export class LoginDetailsSectionComponent implements OnInit {
|
||||
private generationService: CipherFormGenerationService,
|
||||
private auditService: AuditService,
|
||||
private toastService: ToastService,
|
||||
private eventCollectionService: EventCollectionService,
|
||||
@Optional() private totpCaptureService?: TotpCaptureService,
|
||||
) {
|
||||
this.cipherFormContainer.registerChildForm("loginDetails", this.loginDetailsForm);
|
||||
@@ -163,6 +167,24 @@ export class LoginDetailsSectionComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
/** Logs the givin event when in edit mode */
|
||||
logVisibleEvent = async (passwordVisible: boolean, event: EventType) => {
|
||||
const { mode, originalCipher } = this.cipherFormContainer.config;
|
||||
|
||||
const isEdit = ["edit", "partial-edit"].includes(mode);
|
||||
|
||||
if (!passwordVisible || !isEdit || !originalCipher) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.eventCollectionService.collect(
|
||||
event,
|
||||
originalCipher.id,
|
||||
false,
|
||||
originalCipher.organizationId,
|
||||
);
|
||||
};
|
||||
|
||||
captureTotp = async () => {
|
||||
if (!this.canCaptureTotp) {
|
||||
return;
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
bitIconButton
|
||||
bitPasswordInputToggle
|
||||
data-testid="toggle-number"
|
||||
(toggledChange)="logCardEvent($event, EventType.Cipher_ClientToggledCardNumberVisible)"
|
||||
></button>
|
||||
<button
|
||||
bitIconButton="bwi-clone"
|
||||
@@ -69,6 +70,7 @@
|
||||
bitIconButton
|
||||
bitPasswordInputToggle
|
||||
data-testid="toggle-code"
|
||||
(toggledChange)="logCardEvent($event, EventType.Cipher_ClientToggledCardCodeVisible)"
|
||||
></button>
|
||||
<button
|
||||
bitIconButton="bwi-clone"
|
||||
@@ -79,6 +81,7 @@
|
||||
[valueLabel]="'securityCode' | i18n"
|
||||
[appA11yTitle]="'copyValue' | i18n"
|
||||
data-testid="copy-code"
|
||||
(click)="logCardEvent(true, EventType.Cipher_ClientCopiedCardCode)"
|
||||
></button>
|
||||
</bit-form-field>
|
||||
</read-only-cipher-card>
|
||||
|
||||
@@ -2,8 +2,10 @@ import { CommonModule } from "@angular/common";
|
||||
import { Component, Input } from "@angular/core";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
CardComponent,
|
||||
SectionComponent,
|
||||
@@ -32,9 +34,17 @@ import { ReadOnlyCipherCardComponent } from "../read-only-cipher-card/read-only-
|
||||
],
|
||||
})
|
||||
export class CardDetailsComponent {
|
||||
@Input() card: CardView;
|
||||
@Input() cipher: CipherView;
|
||||
EventType = EventType;
|
||||
|
||||
constructor(private i18nService: I18nService) {}
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private eventCollectionService: EventCollectionService,
|
||||
) {}
|
||||
|
||||
get card() {
|
||||
return this.cipher.card;
|
||||
}
|
||||
|
||||
get setSectionTitle() {
|
||||
if (this.card.brand && this.card.brand !== "Other") {
|
||||
@@ -42,4 +52,15 @@ export class CardDetailsComponent {
|
||||
}
|
||||
return this.i18nService.t("cardDetails");
|
||||
}
|
||||
|
||||
async logCardEvent(conditional: boolean, event: EventType) {
|
||||
if (conditional) {
|
||||
await this.eventCollectionService.collect(
|
||||
event,
|
||||
this.cipher.id,
|
||||
false,
|
||||
this.cipher.organizationId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
</app-autofill-options-view>
|
||||
|
||||
<!-- CARD DETAILS -->
|
||||
<app-card-details-view *ngIf="hasCard" [card]="cipher.card"></app-card-details-view>
|
||||
<app-card-details-view *ngIf="hasCard" [cipher]="cipher"></app-card-details-view>
|
||||
|
||||
<!-- IDENTITY SECTIONS -->
|
||||
<app-view-identity-sections *ngIf="cipher.identity" [cipher]="cipher">
|
||||
@@ -42,8 +42,7 @@
|
||||
|
||||
<!-- CUSTOM FIELDS -->
|
||||
<ng-container *ngIf="cipher.fields">
|
||||
<app-custom-fields-v2 [fields]="cipher.fields" [cipherType]="cipher.type">
|
||||
</app-custom-fields-v2>
|
||||
<app-custom-fields-v2 [cipher]="cipher"> </app-custom-fields-v2>
|
||||
</ng-container>
|
||||
|
||||
<!-- ATTACHMENTS SECTION -->
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<bit-card>
|
||||
<div
|
||||
class="tw-border-secondary-300 [&_bit-form-field:last-of-type]:tw-mb-0"
|
||||
*ngFor="let field of fields; let last = last"
|
||||
*ngFor="let field of cipher.fields; let last = last"
|
||||
[ngClass]="{ 'tw-mb-4': !last }"
|
||||
>
|
||||
<bit-form-field *ngIf="field.type === fieldType.Text" [disableReadOnlyBorder]="last">
|
||||
@@ -24,7 +24,13 @@
|
||||
<bit-form-field *ngIf="field.type === fieldType.Hidden" [disableReadOnlyBorder]="last">
|
||||
<bit-label>{{ field.name }}</bit-label>
|
||||
<input readonly bitInput type="password" [value]="field.value" aria-readonly="true" />
|
||||
<button bitSuffix type="button" bitIconButton bitPasswordInputToggle></button>
|
||||
<button
|
||||
bitSuffix
|
||||
type="button"
|
||||
bitIconButton
|
||||
bitPasswordInputToggle
|
||||
(toggledChange)="logHiddenEvent($event)"
|
||||
></button>
|
||||
<button
|
||||
bitIconButton="bwi-clone"
|
||||
bitSuffix
|
||||
@@ -33,6 +39,7 @@
|
||||
showToast
|
||||
[valueLabel]="field.name"
|
||||
[appA11yTitle]="'copyValue' | i18n"
|
||||
(click)="logCopyEvent()"
|
||||
></button>
|
||||
</bit-form-field>
|
||||
<bit-form-control *ngIf="field.type === fieldType.Boolean">
|
||||
|
||||
@@ -2,10 +2,12 @@ import { CommonModule } from "@angular/common";
|
||||
import { Component, Input, OnInit } from "@angular/core";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherType, FieldType, LinkedIdType } from "@bitwarden/common/vault/enums";
|
||||
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
||||
import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view";
|
||||
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
|
||||
import {
|
||||
@@ -37,12 +39,14 @@ import {
|
||||
],
|
||||
})
|
||||
export class CustomFieldV2Component implements OnInit {
|
||||
@Input() fields: FieldView[];
|
||||
@Input() cipherType: CipherType;
|
||||
@Input() cipher: CipherView;
|
||||
fieldType = FieldType;
|
||||
fieldOptions: any;
|
||||
|
||||
constructor(private i18nService: I18nService) {}
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private eventCollectionService: EventCollectionService,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.fieldOptions = this.getLinkedFieldsOptionsForCipher();
|
||||
@@ -53,8 +57,28 @@ export class CustomFieldV2Component implements OnInit {
|
||||
return this.i18nService.t(linkedType.i18nKey);
|
||||
}
|
||||
|
||||
async logHiddenEvent(hiddenFieldVisible: boolean) {
|
||||
if (hiddenFieldVisible) {
|
||||
await this.eventCollectionService.collect(
|
||||
EventType.Cipher_ClientToggledHiddenFieldVisible,
|
||||
this.cipher.id,
|
||||
false,
|
||||
this.cipher.organizationId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async logCopyEvent() {
|
||||
await this.eventCollectionService.collect(
|
||||
EventType.Cipher_ClientCopiedHiddenField,
|
||||
this.cipher.id,
|
||||
false,
|
||||
this.cipher.organizationId,
|
||||
);
|
||||
}
|
||||
|
||||
private getLinkedFieldsOptionsForCipher() {
|
||||
switch (this.cipherType) {
|
||||
switch (this.cipher.type) {
|
||||
case CipherType.Login:
|
||||
return LoginView.prototype.linkedFieldOptions;
|
||||
case CipherType.Card:
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
showToast
|
||||
[appA11yTitle]="'copyValue' | i18n"
|
||||
data-testid="copy-password"
|
||||
(click)="logCopyEvent()"
|
||||
></button>
|
||||
</bit-form-field>
|
||||
<div
|
||||
|
||||
@@ -4,7 +4,9 @@ import { Router } from "@angular/router";
|
||||
import { Observable, shareReplay } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
@@ -61,6 +63,7 @@ export class LoginCredentialsViewComponent {
|
||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
private router: Router,
|
||||
private i18nService: I18nService,
|
||||
private eventCollectionService: EventCollectionService,
|
||||
) {}
|
||||
|
||||
get fido2CredentialCreationDateValue(): string {
|
||||
@@ -76,8 +79,17 @@ export class LoginCredentialsViewComponent {
|
||||
await this.router.navigate(["/premium"]);
|
||||
}
|
||||
|
||||
pwToggleValue(evt: boolean) {
|
||||
this.passwordRevealed = evt;
|
||||
async pwToggleValue(passwordVisible: boolean) {
|
||||
this.passwordRevealed = passwordVisible;
|
||||
|
||||
if (passwordVisible) {
|
||||
await this.eventCollectionService.collect(
|
||||
EventType.Cipher_ClientToggledPasswordVisible,
|
||||
this.cipher.id,
|
||||
false,
|
||||
this.cipher.organizationId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
togglePasswordCount() {
|
||||
@@ -87,4 +99,13 @@ export class LoginCredentialsViewComponent {
|
||||
setTotpCopyCode(e: TotpCodeValues) {
|
||||
this.totpCodeCopyObj = e;
|
||||
}
|
||||
|
||||
async logCopyEvent() {
|
||||
await this.eventCollectionService.collect(
|
||||
EventType.Cipher_ClientCopiedPassword,
|
||||
this.cipher.id,
|
||||
false,
|
||||
this.cipher.organizationId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +158,8 @@ describe("CopyCipherFieldService", () => {
|
||||
expect(eventCollectionService.collect).toHaveBeenCalledWith(
|
||||
EventType.Cipher_ClientCopiedPassword,
|
||||
cipher.id,
|
||||
false,
|
||||
cipher.organizationId,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,7 +125,12 @@ export class CopyCipherFieldService {
|
||||
});
|
||||
|
||||
if (action.event !== undefined) {
|
||||
await this.eventCollectionService.collect(action.event, cipher.id);
|
||||
await this.eventCollectionService.collect(
|
||||
action.event,
|
||||
cipher.id,
|
||||
false,
|
||||
cipher.organizationId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
52
package-lock.json
generated
52
package-lock.json
generated
@@ -66,7 +66,7 @@
|
||||
"qrious": "4.0.2",
|
||||
"rxjs": "7.8.1",
|
||||
"tabbable": "6.2.0",
|
||||
"tldts": "6.1.41",
|
||||
"tldts": "6.1.46",
|
||||
"utf-8-validate": "6.0.4",
|
||||
"zone.js": "0.13.3",
|
||||
"zxcvbn": "4.4.2"
|
||||
@@ -108,7 +108,7 @@
|
||||
"@types/koa-json": "2.0.23",
|
||||
"@types/lowdb": "1.0.15",
|
||||
"@types/lunr": "2.3.7",
|
||||
"@types/node": "20.16.4",
|
||||
"@types/node": "20.16.5",
|
||||
"@types/node-fetch": "2.6.4",
|
||||
"@types/node-forge": "1.3.11",
|
||||
"@types/node-ipc": "9.2.3",
|
||||
@@ -119,7 +119,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "7.16.1",
|
||||
"@typescript-eslint/parser": "7.16.1",
|
||||
"@webcomponents/custom-elements": "1.6.0",
|
||||
"@yao-pkg/pkg": "5.12.1",
|
||||
"@yao-pkg/pkg": "5.14.0",
|
||||
"autoprefixer": "10.4.20",
|
||||
"babel-loader": "9.1.3",
|
||||
"base64-loader": "1.0.0",
|
||||
@@ -179,7 +179,7 @@
|
||||
"typescript": "5.1.6",
|
||||
"url": "0.11.3",
|
||||
"util": "0.12.5",
|
||||
"wait-on": "8.0.0",
|
||||
"wait-on": "8.0.1",
|
||||
"webpack": "5.94.0",
|
||||
"webpack-cli": "5.1.4",
|
||||
"webpack-dev-server": "5.0.4",
|
||||
@@ -223,7 +223,7 @@
|
||||
"papaparse": "5.4.1",
|
||||
"proper-lockfile": "4.1.2",
|
||||
"rxjs": "7.8.1",
|
||||
"tldts": "6.1.41",
|
||||
"tldts": "6.1.46",
|
||||
"zxcvbn": "4.4.2"
|
||||
},
|
||||
"bin": {
|
||||
@@ -9401,9 +9401,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.16.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.4.tgz",
|
||||
"integrity": "sha512-ioyQ1zK9aGEomJ45zz8S8IdzElyxhvP1RVWnPrXDf6wFaUb+kk1tEcVVJkF7RPGM0VWI7cp5U57oCPIn5iN1qg==",
|
||||
"version": "20.16.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.5.tgz",
|
||||
"integrity": "sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -10828,16 +10828,16 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@yao-pkg/pkg": {
|
||||
"version": "5.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@yao-pkg/pkg/-/pkg-5.12.1.tgz",
|
||||
"integrity": "sha512-vqp8Z9o39LDKTpjfeDjJsLf4mi0zS4jkbTTZbptfc/K1KKDU2hosex64TaattPO9NLkibc6EJldmSjVmc63ooA==",
|
||||
"version": "5.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@yao-pkg/pkg/-/pkg-5.14.0.tgz",
|
||||
"integrity": "sha512-34oflUyAOI64a4cc4AF3ckvS8Qqnk/ISvZ1bDBa1/JAYaaFtzAO+RlhPaU+wCHzhk6VXvZwEywJpb+SlVDTgdA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/generator": "7.23.0",
|
||||
"@babel/parser": "7.23.0",
|
||||
"@babel/types": "7.23.0",
|
||||
"@yao-pkg/pkg-fetch": "3.5.9",
|
||||
"@yao-pkg/pkg-fetch": "3.5.11",
|
||||
"chalk": "^4.1.2",
|
||||
"fs-extra": "^9.1.0",
|
||||
"globby": "^11.1.0",
|
||||
@@ -10854,9 +10854,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@yao-pkg/pkg-fetch": {
|
||||
"version": "3.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@yao-pkg/pkg-fetch/-/pkg-fetch-3.5.9.tgz",
|
||||
"integrity": "sha512-usMwwqFCd2B7k+V87u6kiTesyDSlw+3LpiuYBWe+UgryvSOk/NXjx3XVCub8hQoi0bCREbdQ6NDBqminyHJJrg==",
|
||||
"version": "3.5.11",
|
||||
"resolved": "https://registry.npmjs.org/@yao-pkg/pkg-fetch/-/pkg-fetch-3.5.11.tgz",
|
||||
"integrity": "sha512-2tQ/1n7BLTptW6lL0pfTCnVMIxls8Jiw0/ClK1J2Fja9z2S2j4uzNL5dwGRqtvPJPn/q9i8X+Y+c4dwnMb+NOA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -36258,21 +36258,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "6.1.41",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.41.tgz",
|
||||
"integrity": "sha512-RNpUkL5fYD2DTQQCdr8QMDp6UL0ThtpXT3q3+qPE05dIT+RK2I3M0VByVbQN1dEhLUGzimivVwxK2By9epLk6w==",
|
||||
"version": "6.1.46",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.46.tgz",
|
||||
"integrity": "sha512-fw81lXV2CijkNrZAZvee7wegs+EOlTyIuVl/z4q6OUzZHQ1jGL2xQzKXq9geYf/1tzo9LZQLrkcko2m8HLh+rg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^6.1.41"
|
||||
"tldts-core": "^6.1.46"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "6.1.41",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.41.tgz",
|
||||
"integrity": "sha512-SkwZgo1ZzMp2ziMBwci5VBnLR9VywCi02jSgMX5TO5kf9fdaBsxZkblLff3NlJNTcH0vfvEsgw2B7jVR556Vgw==",
|
||||
"version": "6.1.46",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.46.tgz",
|
||||
"integrity": "sha512-zA3ai/j4aFcmbqTvTONkSBuWs0Q4X4tJxa0gV9sp6kDbq5dAhQDSg0WUkReEm0fBAKAGNj+wPKCCsR8MYOYmwA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
@@ -38266,13 +38266,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wait-on": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-8.0.0.tgz",
|
||||
"integrity": "sha512-fNE5SXinLr2Bt7cJvjvLg2PcXfqznlqRvtE3f8AqYdRZ9BhE+XpsCp1mwQbRoO7s1q7uhAuCw0Ro3mG/KdZjEw==",
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-8.0.1.tgz",
|
||||
"integrity": "sha512-1wWQOyR2LVVtaqrcIL2+OM+x7bkpmzVROa0Nf6FryXkS+er5Sa1kzFGjzZRqLnHa3n1rACFLeTwUqE1ETL9Mig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.7.4",
|
||||
"axios": "^1.7.7",
|
||||
"joi": "^17.13.3",
|
||||
"lodash": "^4.17.21",
|
||||
"minimist": "^1.2.8",
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
"@types/koa-json": "2.0.23",
|
||||
"@types/lowdb": "1.0.15",
|
||||
"@types/lunr": "2.3.7",
|
||||
"@types/node": "20.16.4",
|
||||
"@types/node": "20.16.5",
|
||||
"@types/node-fetch": "2.6.4",
|
||||
"@types/node-forge": "1.3.11",
|
||||
"@types/node-ipc": "9.2.3",
|
||||
@@ -81,7 +81,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "7.16.1",
|
||||
"@typescript-eslint/parser": "7.16.1",
|
||||
"@webcomponents/custom-elements": "1.6.0",
|
||||
"@yao-pkg/pkg": "5.12.1",
|
||||
"@yao-pkg/pkg": "5.14.0",
|
||||
"autoprefixer": "10.4.20",
|
||||
"babel-loader": "9.1.3",
|
||||
"base64-loader": "1.0.0",
|
||||
@@ -141,7 +141,7 @@
|
||||
"typescript": "5.1.6",
|
||||
"url": "0.11.3",
|
||||
"util": "0.12.5",
|
||||
"wait-on": "8.0.0",
|
||||
"wait-on": "8.0.1",
|
||||
"webpack": "5.94.0",
|
||||
"webpack-cli": "5.1.4",
|
||||
"webpack-dev-server": "5.0.4",
|
||||
@@ -199,7 +199,7 @@
|
||||
"qrious": "4.0.2",
|
||||
"rxjs": "7.8.1",
|
||||
"tabbable": "6.2.0",
|
||||
"tldts": "6.1.41",
|
||||
"tldts": "6.1.46",
|
||||
"utf-8-validate": "6.0.4",
|
||||
"zone.js": "0.13.3",
|
||||
"zxcvbn": "4.4.2"
|
||||
|
||||
Reference in New Issue
Block a user