1
0
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:
Victoria League
2024-09-16 15:32:14 -04:00
committed by GitHub
64 changed files with 2075 additions and 658 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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."
},

View File

@@ -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 }}

View File

@@ -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>

View File

@@ -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();
}
}

View File

@@ -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>

View File

@@ -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.

View File

@@ -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 () => {

View File

@@ -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;
}
/**

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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];

View File

@@ -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);

View File

@@ -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,
);
}
}

View File

@@ -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,
);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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,

View File

@@ -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);
});
});

View File

@@ -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.

View File

@@ -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],

View File

@@ -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,

View File

@@ -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?

View File

@@ -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 = {

View File

@@ -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;
}),
)

View File

@@ -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,
);
}));
});
});

View File

@@ -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,

View File

@@ -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,

View File

@@ -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"
}
}

View File

@@ -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)]

View File

@@ -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"

View File

@@ -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"
},

View File

@@ -22,7 +22,7 @@
bitCheckbox
formControlName="requireMasterPasswordOnClientRestart"
/>
<span>{{ "lockWithMasterPassOnRestart" | i18n }}</span>
<span>{{ "lockWithMasterPassOnRestart1" | i18n }}</span>
</label>
</div>
<div bitDialogFooter>

View File

@@ -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",

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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"

View File

@@ -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.

View File

@@ -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"

View File

@@ -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

View File

@@ -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>

View File

@@ -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 };
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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 } },
],

View File

@@ -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;

View File

@@ -46,6 +46,7 @@
bitPasswordInputToggle
data-testid="visibility-for-custom-hidden-field"
[disabled]="!canViewPasswords(i)"
(toggledChange)="logHiddenEvent($event)"
></button>
</bit-form-field>

View File

@@ -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(" ") },

View File

@@ -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
*

View File

@@ -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"

View File

@@ -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();

View File

@@ -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;

View File

@@ -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>

View File

@@ -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,
);
}
}
}

View File

@@ -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 -->

View File

@@ -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">

View File

@@ -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:

View File

@@ -66,6 +66,7 @@
showToast
[appA11yTitle]="'copyValue' | i18n"
data-testid="copy-password"
(click)="logCopyEvent()"
></button>
</bit-form-field>
<div

View File

@@ -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,
);
}
}

View File

@@ -158,6 +158,8 @@ describe("CopyCipherFieldService", () => {
expect(eventCollectionService.collect).toHaveBeenCalledWith(
EventType.Cipher_ClientCopiedPassword,
cipher.id,
false,
cipher.organizationId,
);
});
});

View File

@@ -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
View File

@@ -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",

View File

@@ -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"