1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-09 21:20:27 +00:00

Fixed conflicts

This commit is contained in:
gbubemismith
2025-04-21 12:22:54 -04:00
240 changed files with 3036 additions and 11595 deletions

3
.github/CODEOWNERS vendored
View File

@@ -8,7 +8,8 @@
apps/desktop/desktop_native @bitwarden/team-platform-dev
apps/desktop/desktop_native/objc/src/native/autofill @bitwarden/team-autofill-dev
apps/desktop/desktop_native/core/src/autofill @bitwarden/team-autofill-dev
## No ownership for Cargo.toml to allow dependency updates
## No ownership fo Cargo.lock and Cargo.toml to allow dependency updates
apps/desktop/desktop_native/Cargo.lock
apps/desktop/desktop_native/Cargo.toml
## Auth team files ##

View File

@@ -49,6 +49,7 @@
"./github/workflows/release-web.yml",
],
commitMessagePrefix: "[deps] BRE:",
addLabels: ["hold"],
},
{
// Disable major and minor updates for TypeScript and Zone.js because they are managed by Angular.

View File

@@ -12,12 +12,13 @@ on:
- 'cf-pages'
paths:
- 'apps/cli/**'
- 'bitwarden_license/bit-cli/**'
- 'bitwarden_license/bit-common/**'
- 'libs/**'
- '*'
- '!*.md'
- '!*.txt'
- '.github/workflows/build-cli.yml'
- 'bitwarden_license/bit-cli/**'
push:
branches:
- 'main'
@@ -25,12 +26,13 @@ on:
- 'hotfix-rc-cli'
paths:
- 'apps/cli/**'
- 'bitwarden_license/bit-cli/**'
- 'bitwarden_license/bit-common/**'
- 'libs/**'
- '*'
- '!*.md'
- '!*.txt'
- '.github/workflows/build-cli.yml'
- 'bitwarden_license/bit-cli/**'
workflow_call:
inputs: {}
workflow_dispatch:
@@ -87,6 +89,7 @@ jobs:
os:
[
{ base: "linux", distro: "ubuntu-22.04", target_suffix: "" },
{ base: "linux", distro: "ubuntu-22.04-arm", target_suffix: "-arm64" },
{ base: "mac", distro: "macos-13", target_suffix: "" },
{ base: "mac", distro: "macos-14", target_suffix: "-arm64" }
]
@@ -130,7 +133,7 @@ jobs:
if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }}
uses: bitwarden/gh-actions/download-artifacts@main
with:
github_token: ${{secrets.GITHUB_TOKEN}}
github_token: ${{ secrets.GITHUB_TOKEN }}
workflow: build-wasm-internal.yml
workflow_conclusion: success
branch: ${{ inputs.sdk_branch }}
@@ -306,7 +309,7 @@ jobs:
if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }}
uses: bitwarden/gh-actions/download-artifacts@main
with:
github_token: ${{secrets.GITHUB_TOKEN}}
github_token: ${{ secrets.GITHUB_TOKEN }}
workflow: build-wasm-internal.yml
workflow_conclusion: success
branch: ${{ inputs.sdk_branch }}

View File

@@ -201,7 +201,7 @@ jobs:
if: ${{ inputs.sdk_branch != '' }}
uses: bitwarden/gh-actions/download-artifacts@main
with:
github_token: ${{secrets.GITHUB_TOKEN}}
github_token: ${{ secrets.GITHUB_TOKEN }}
workflow: build-wasm-internal.yml
workflow_conclusion: success
branch: ${{ inputs.sdk_branch }}
@@ -237,7 +237,7 @@ jobs:
TARGET: musl
run: |
rustup target add x86_64-unknown-linux-musl
node build.js cross-platform
node build.js --target=x86_64-unknown-linux-musl --release
- name: Build application
run: npm run dist:lin
@@ -298,6 +298,103 @@ jobs:
if-no-files-found: error
linux-arm64:
name: Linux ARM64 Build
# Note, before updating the ubuntu version of the workflow, ensure the snap base image
# is equal or greater than the new version. Otherwise there might be GLIBC version issues.
# The snap base for desktop is defined in `apps/desktop/electron-builder.json`
# We intentionally keep this runner on the oldest supported OS in GitHub Actions
# for maximum compatibility across GLIBC versions
runs-on: ubuntu-22.04-arm
needs: setup
env:
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
NODE_OPTIONS: --max_old_space_size=4096
defaults:
run:
working-directory: apps/desktop
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Node
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
node-version: ${{ env._NODE_VERSION }}
- name: Set up environment
run: |
sudo apt-get update
sudo apt-get -y install pkg-config libxss-dev rpm musl-dev musl-tools flatpak flatpak-builder
- name: Print environment
run: |
node --version
npm --version
snap --version
snapcraft --version || echo 'snapcraft unavailable'
- name: Install Node dependencies
run: npm ci
working-directory: ./
- name: Download SDK Artifacts
if: ${{ inputs.sdk_branch != '' }}
uses: bitwarden/gh-actions/download-artifacts@main
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
workflow: build-wasm-internal.yml
workflow_conclusion: success
branch: ${{ inputs.sdk_branch }}
artifacts: sdk-internal
repo: bitwarden/sdk-internal
path: ../sdk-internal
if_no_artifact_found: fail
- name: Override SDK
if: ${{ inputs.sdk_branch != '' }}
working-directory: ./
run: |
ls -l ../
npm link ../sdk-internal
- name: Cache Native Module
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
id: cache
with:
path: |
apps/desktop/desktop_native/napi/*.node
apps/desktop/desktop_native/dist/*
${{ env.RUNNER_TEMP }}/.cargo/registry
${{ env.RUNNER_TEMP }}/.cargo/git
key: rust-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('apps/desktop/desktop_native/**/*') }}
- name: Build Native Module
if: steps.cache.outputs.cache-hit != 'true'
working-directory: apps/desktop/desktop_native
env:
PKG_CONFIG_ALLOW_CROSS: true
PKG_CONFIG_ALL_STATIC: true
TARGET: musl
run: |
rustup target add aarch64-unknown-linux-musl
node build.js --target=aarch64-unknown-linux-musl --release
- name: Build application
run: npm run dist:lin:arm64
- name: Upload tar.gz artifact
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
with:
name: bitwarden_${{ env._PACKAGE_VERSION }}_arm64.tar.gz
path: apps/desktop/dist/bitwarden_desktop_arm64.tar.gz
if-no-files-found: error
windows:
name: Windows Build
runs-on: windows-2022
@@ -369,7 +466,7 @@ jobs:
if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }}
uses: bitwarden/gh-actions/download-artifacts@main
with:
github_token: ${{secrets.GITHUB_TOKEN}}
github_token: ${{ secrets.GITHUB_TOKEN }}
workflow: build-wasm-internal.yml
workflow_conclusion: success
branch: ${{ inputs.sdk_branch }}
@@ -705,7 +802,7 @@ jobs:
if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }}
uses: bitwarden/gh-actions/download-artifacts@main
with:
github_token: ${{secrets.GITHUB_TOKEN}}
github_token: ${{ secrets.GITHUB_TOKEN }}
workflow: build-wasm-internal.yml
workflow_conclusion: success
branch: ${{ inputs.sdk_branch }}
@@ -895,7 +992,7 @@ jobs:
if: ${{ inputs.sdk_branch != '' }}
uses: bitwarden/gh-actions/download-artifacts@main
with:
github_token: ${{secrets.GITHUB_TOKEN}}
github_token: ${{ secrets.GITHUB_TOKEN }}
workflow: build-wasm-internal.yml
workflow_conclusion: success
branch: ${{ inputs.sdk_branch }}
@@ -1144,7 +1241,7 @@ jobs:
if: ${{ inputs.sdk_branch != '' }}
uses: bitwarden/gh-actions/download-artifacts@main
with:
github_token: ${{secrets.GITHUB_TOKEN}}
github_token: ${{ secrets.GITHUB_TOKEN }}
workflow: build-wasm-internal.yml
workflow_conclusion: success
branch: ${{ inputs.sdk_branch }}
@@ -1425,7 +1522,7 @@ jobs:
if: ${{ inputs.sdk_branch != '' }}
uses: bitwarden/gh-actions/download-artifacts@main
with:
github_token: ${{secrets.GITHUB_TOKEN}}
github_token: ${{ secrets.GITHUB_TOKEN }}
workflow: build-wasm-internal.yml
workflow_conclusion: success
branch: ${{ inputs.sdk_branch }}

View File

@@ -12,6 +12,8 @@ on:
- 'cf-pages'
paths:
- 'apps/web/**'
- 'bitwarden_license/bit-common/**'
- 'bitwarden_license/bit-web/**'
- 'libs/**'
- '*'
- '!*.md'
@@ -24,6 +26,8 @@ on:
- 'hotfix-rc-web'
paths:
- 'apps/web/**'
- 'bitwarden_license/bit-common/**'
- 'bitwarden_license/bit-web/**'
- 'libs/**'
- '*'
- '!*.md'

View File

@@ -141,3 +141,36 @@ jobs:
- name: Log out of Docker
run: docker logout
self-host-unified-build:
name: Trigger self-host unified build
runs-on: ubuntu-22.04
needs:
- setup
steps:
- name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- name: Retrieve GitHub PAT secrets
id: retrieve-secret-pat
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Trigger self-host build
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
script: |
await github.rest.actions.createWorkflowDispatch({
owner: 'bitwarden',
repo: 'self-host',
workflow_id: 'build-unified.yml',
ref: 'main',
inputs: {
use_latest_core_version: true
}
});

View File

@@ -1,6 +1,6 @@
{
"name": "@bitwarden/browser",
"version": "2025.3.2",
"version": "2025.4.0",
"scripts": {
"build": "npm run build:chrome",
"build:chrome": "cross-env BROWSER=chrome MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack",

View File

@@ -2,6 +2,9 @@
"appName": {
"message": "Bitwarden"
},
"appLogoLabel": {
"message": "Bitwarden logo"
},
"extName": {
"message": "Bitwarden Password Manager",
"description": "Extension name, MUST be less than 40 characters (Safari restriction)"
@@ -4928,8 +4931,8 @@
"message": "Password regenerated",
"description": "Notification message for when a password has been regenerated"
},
"saveLoginToBitwarden": {
"message": "Save login to Bitwarden?",
"saveToBitwarden": {
"message": "Save to Bitwarden",
"description": "Confirmation message for saving a login to Bitwarden"
},
"spaceCharacterDescriptor": {

View File

@@ -5,7 +5,12 @@
[showBackButton]="showBackButton"
[pageTitle]="''"
>
<bit-icon *ngIf="showLogo" class="tw-inline-flex" [icon]="logo"></bit-icon>
<bit-icon
*ngIf="showLogo"
class="tw-inline-flex"
[icon]="logo"
[ariaLabel]="'appLogoLabel' | i18n"
></bit-icon>
<ng-container slot="end">
<app-pop-out></app-pop-out>

View File

@@ -12,6 +12,7 @@ import {
} from "@bitwarden/auth/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Icon, IconModule, Translation } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
@@ -36,6 +37,7 @@ export interface ExtensionAnonLayoutWrapperData extends AnonLayoutWrapperData {
AnonLayoutComponent,
CommonModule,
CurrentAccountComponent,
I18nPipe,
IconModule,
PopOutComponent,
PopupPageComponent,

View File

@@ -16,6 +16,7 @@ import {
} from "@bitwarden/common/autofill/constants";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service";
import { ProductTierType } from "@bitwarden/common/billing/enums/product-tier-type.enum";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { NeverDomains } from "@bitwarden/common/models/domain/domain-service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@@ -41,7 +42,11 @@ import { SecurityTask } from "@bitwarden/common/vault/tasks/models/security-task
import { openUnlockPopout } from "../../auth/popup/utils/auth-popout-window";
import { BrowserApi } from "../../platform/browser/browser-api";
import { openAddEditVaultItemPopout } from "../../vault/popup/utils/vault-popout-window";
import { NotificationCipherData } from "../content/components/cipher/types";
import {
OrganizationCategory,
OrganizationCategories,
NotificationCipherData,
} from "../content/components/cipher/types";
import { NotificationQueueMessageType } from "../enums/notification-queue-message-type.enum";
import { AutofillService } from "../services/abstractions/autofill.service";
@@ -174,8 +179,29 @@ export default class NotificationBackground {
activeUserId,
);
const organizations = await firstValueFrom(
this.organizationService.organizations$(activeUserId),
);
return decryptedCiphers.map((view) => {
const { id, name, reprompt, favorite, login } = view;
const { id, name, reprompt, favorite, login, organizationId } = view;
const organizationType = organizationId
? organizations.find((org) => org.id === organizationId)?.productTierType
: null;
const organizationCategories: OrganizationCategory[] = [];
if (
[ProductTierType.Teams, ProductTierType.Enterprise, ProductTierType.TeamsStarter].includes(
organizationType,
)
) {
organizationCategories.push(OrganizationCategories.business);
}
if ([ProductTierType.Families, ProductTierType.Free].includes(organizationType)) {
organizationCategories.push(OrganizationCategories.family);
}
return {
id,
@@ -183,6 +209,7 @@ export default class NotificationBackground {
type: CipherType.Login,
reprompt,
favorite,
...(organizationCategories.length ? { organizationCategories } : {}),
icon: buildCipherIcon(iconsServerUrl, view, showFavicons),
login: login && {
username: login.username,

View File

@@ -1852,7 +1852,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
/**
* Verifies whether the save login inline menu view should be shown. This requires that
* the login data on the page contains a username and either a current or new password.
* the login data on the page contains either a current or new password.
*
* @param tab - The tab to check for login data
*/
@@ -1869,7 +1869,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
return (
(this.shouldShowInlineMenuAccountCreation() ||
this.focusedFieldMatchesFillType(InlineMenuFillType.PasswordGeneration)) &&
!!(loginData.username && (loginData.password || loginData.newPassword))
!!(loginData.password || loginData.newPassword)
);
}
@@ -2157,7 +2157,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
"passwordRegenerated",
"passwords",
"regeneratePassword",
"saveLoginToBitwarden",
"saveToBitwarden",
"toggleBitwardenVaultOverlay",
"totpCodeAria",
"totpSecondsSpanAria",

View File

@@ -1,7 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import MainBackground from "../../background/main.background";
import { OverlayBackground } from "./abstractions/overlay.background";
@@ -14,7 +10,7 @@ export default class TabsBackground {
private overlayBackground: OverlayBackground,
) {}
private focusedWindowId: number;
private focusedWindowId: number = -1;
/**
* Initializes the window and tab listeners.
@@ -90,14 +86,6 @@ export default class TabsBackground {
changeInfo: chrome.tabs.TabChangeInfo,
tab: chrome.tabs.Tab,
) => {
const overlayImprovementsFlag = await this.main.configService.getFeatureFlag(
FeatureFlag.InlineMenuPositioningImprovements,
);
const removePageDetailsStatus = new Set(["loading", "unloaded"]);
if (!overlayImprovementsFlag && removePageDetailsStatus.has(changeInfo.status)) {
this.overlayBackground.removePageDetails(tabId);
}
if (this.focusedWindowId > 0 && tab.windowId !== this.focusedWindowId) {
return;
}

View File

@@ -35,5 +35,6 @@ const closeButtonStyles = (theme: Theme) => css`
> svg {
width: 20px;
height: 20px;
vertical-align: middle;
}
`;

View File

@@ -44,7 +44,7 @@ export function OptionSelectionButton({
`;
}
const iconSize = "15px";
const iconSize = "16px";
const selectionButtonStyles = ({
disabled,
@@ -94,7 +94,8 @@ const selectionButtonStyles = ({
> svg {
max-width: ${iconSize};
height: fit-content;
max-height: ${iconSize};
height: auto;
}
`;

View File

@@ -19,13 +19,13 @@ export function CipherAction({
? BadgeButton({
buttonAction: handleAction,
// @TODO localize
buttonText: "Update item",
buttonText: "Update",
theme,
})
: EditButton({
buttonAction: handleAction,
// @TODO localize
buttonText: "Edit item",
buttonText: "Edit",
theme,
});
}

View File

@@ -1,30 +1,35 @@
import { css } from "@emotion/css";
import { html } from "lit";
import { html, TemplateResult } from "lit";
import { Theme } from "@bitwarden/common/platform/enums";
import { themes } from "../../../content/components/constants/styles";
import { Business, Family } from "../../../content/components/icons";
// @TODO connect data source to icon checks
// @TODO support other indicator types (attachments, etc)
import { OrganizationCategories, OrganizationCategory } from "./types";
const cipherIndicatorIconsMap: Record<
OrganizationCategory,
(args: { color: string; theme: Theme }) => TemplateResult
> = {
[OrganizationCategories.business]: Business,
[OrganizationCategories.family]: Family,
};
export function CipherInfoIndicatorIcons({
showBusinessIcon,
showFamilyIcon,
organizationCategories = [],
theme,
}: {
showBusinessIcon?: boolean;
showFamilyIcon?: boolean;
organizationCategories?: OrganizationCategory[];
theme: Theme;
}) {
const indicatorIcons = [
...(showBusinessIcon ? [Business({ color: themes[theme].text.muted, theme })] : []),
...(showFamilyIcon ? [Family({ color: themes[theme].text.muted, theme })] : []),
];
return indicatorIcons.length
? html` <span class=${cipherInfoIndicatorIconsStyles}> ${indicatorIcons} </span> `
: null; // @TODO null case should be handled by parent
return html`
<span class=${cipherInfoIndicatorIconsStyles}>
${organizationCategories.map((name) =>
cipherIndicatorIconsMap[name]?.({ color: themes[theme].text.muted, theme }),
)}
</span>
`;
}
const cipherInfoIndicatorIconsStyles = css`

View File

@@ -1,5 +1,5 @@
import { css } from "@emotion/css";
import { html } from "lit";
import { html, nothing } from "lit";
import { Theme } from "@bitwarden/common/platform/enums";
@@ -8,14 +8,22 @@ import { themes, typography } from "../../../content/components/constants/styles
import { CipherInfoIndicatorIcons } from "./cipher-indicator-icons";
import { NotificationCipherData } from "./types";
// @TODO support other cipher types (card, identity, notes, etc)
export function CipherInfo({ cipher, theme }: { cipher: NotificationCipherData; theme: Theme }) {
const { name, login } = cipher;
const { name, login, organizationCategories } = cipher;
const hasIndicatorIcons = organizationCategories?.length;
return html`
<div>
<span class=${cipherInfoPrimaryTextStyles(theme)}>
${[name, CipherInfoIndicatorIcons({ theme })]}
${[
name,
hasIndicatorIcons
? CipherInfoIndicatorIcons({
theme,
organizationCategories,
})
: nothing,
]}
</span>
${login?.username

View File

@@ -14,6 +14,14 @@ export const CipherRepromptTypes = {
type CipherRepromptType = (typeof CipherRepromptTypes)[keyof typeof CipherRepromptTypes];
export type OrganizationCategory =
(typeof OrganizationCategories)[keyof typeof OrganizationCategories];
export const OrganizationCategories = {
business: "business",
family: "family",
} as const;
export type WebsiteIconData = {
imageEnabled: boolean;
image: string;
@@ -50,4 +58,5 @@ export type NotificationCipherData = BaseCipherData<typeof CipherTypes.Login> &
login?: {
username: string;
};
organizationCategories?: OrganizationCategory[];
};

View File

@@ -8,10 +8,10 @@ export function AngleDown({ color, disabled, theme }: IconProps) {
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
return html`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 12" fill="none">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 8" fill="none">
<path
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
d="M12.004 11.244a2.705 2.705 0 0 1-1.75-.644L.266 2.154a.76.76 0 0 1-.263-.51.75.75 0 0 1 1.233-.637l9.99 8.445a1.186 1.186 0 0 0 1.565 0l10-8.54a.751.751 0 0 1 .973 1.141l-10 8.538a2.703 2.703 0 0 1-1.76.653Z"
d="M13.53.47a.75.75 0 0 0-1.06 0L7 5.94 1.53.47A.75.75 0 1 0 .47 1.53l6 6a.75.75 0 0 0 1.06 0l6-6a.75.75 0 0 0 0-1.06Z"
/>
</svg>
`;

View File

@@ -8,15 +8,10 @@ export function AngleUp({ color, disabled, theme }: IconProps) {
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
return html`
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 12"
fill="none"
style="transform: scaleY(-1);"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 8" fill="none">
<path
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
d="M12.004 11.244a2.705 2.705 0 0 1-1.75-.644L.266 2.154a.76.76 0 0 1-.263-.51.75.75 0 0 1 1.233-.637l9.99 8.445a1.186 1.186 0 0 0 1.565 0l10-8.54a.751.751 0 0 1 .973 1.141l-10 8.538a2.703 2.703 0 0 1-1.76.653Z"
d="M.47 7.53a.75.75 0 0 0 1.06 0L7 2.06l5.47 5.47a.75.75 0 1 0 1.06-1.06l-6-6a.75.75 0 0 0-1.06 0l-6 6a.75.75 0 0 0 0 1.06Z"
/>
</svg>
`;

View File

@@ -12,8 +12,13 @@ export function BrandIconContainer({ iconLink, theme }: { iconLink?: URL; theme:
}
const brandIconContainerStyles = css`
display: flex;
justify-content: center;
width: 24px;
height: 24px;
> svg {
width: 20px;
height: fit-content;
width: auto;
height: 100%;
}
`;

View File

@@ -8,30 +8,17 @@ export function Business({ color, disabled, theme }: IconProps) {
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
return html`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 16" fill="none">
<path
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
fill-rule="evenodd"
d="M6.015 16.482a3.007 3.007 0 1 0 0-6.015 3.007 3.007 0 0 0 0 6.015Zm0 1.504a4.51 4.51 0 1 0 0-9.022 4.51 4.51 0 0 0 0 9.022Z"
clip-rule="evenodd"
d="M3.25 3a.75.75 0 0 0 0 1.5h1.5a.75.75 0 0 0 0-1.5h-1.5ZM7.25 3a.75.75 0 0 0 0 1.5h1.5a.75.75 0 0 0 0-1.5h-1.5ZM7.25 6a.75.75 0 0 0 0 1.5h1.5a.75.75 0 0 0 0-1.5h-1.5ZM6.5 9.75A.75.75 0 0 1 7.25 9h1.5a.75.75 0 0 1 0 1.5h-1.5a.75.75 0 0 1-.75-.75ZM2.5 6.75A.75.75 0 0 1 3.25 6h1.5a.75.75 0 0 1 0 1.5h-1.5a.75.75 0 0 1-.75-.75ZM3.25 9a.75.75 0 0 0 0 1.5h1.5a.75.75 0 0 0 0-1.5h-1.5Z"
/>
<path
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
fill-rule="evenodd"
d="M10.439 22.497c-.548-2.805-2.51-4.511-4.427-4.511-1.917 0-3.879 1.706-4.426 4.51h8.853Zm-8.934.525v-.002.002ZM.659 24h10.466c.645 0 .984-.424.888-1.18-.457-3.591-2.97-6.338-6-6.338-3.032 0-5.544 2.747-6.001 6.339-.066.511.143 1.18.647 1.18Z"
d="M1 0a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V1a1 1 0 0 0-1-1H1Zm.5 1.5v13H4V13a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5h2.5v-13h-9Z"
clip-rule="evenodd"
/>
<path
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
fill-rule="evenodd"
d="M7.46 1.387v7.577a.694.694 0 1 1-1.387 0V.97c0-.536.434-.971.97-.971H23.03c.536 0 .971.435.971.971v20.496a.971.971 0 0 1-.971.971h-11a.694.694 0 0 1 0-1.387h10.584V1.387H7.46Z"
clip-rule="evenodd"
/>
<path
class=${css(buildIconColorRule(shapeColor, ruleNames.stroke))}
stroke-linecap="round"
d="M14.033 3.953h2.007M9.522 3.953h2.007M18.544 3.953h2.007M14.033 8.464h2.007M9.522 8.464h2.007M18.544 8.464h2.007M14.033 12.975h2.007M9.522 12.975h2.007M18.544 12.975h2.007M14.033 17.485h2.007M18.544 17.485h2.007"
/>
</svg>
`;
}

View File

@@ -8,10 +8,10 @@ export function Close({ color, disabled, theme }: IconProps) {
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
return html`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14" fill="none">
<path
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
d="m19.809 19.21-8.487-8.226a.592.592 0 0 1 0-.852l8.382-8.13a.594.594 0 0 0 .175-.423.593.593 0 0 0-.182-.42.632.632 0 0 0-.872-.007l-8.383 8.126a.634.634 0 0 1-.88 0l-8.41-8.135a.642.642 0 0 0-.887-.008.602.602 0 0 0-.182.431.588.588 0 0 0 .19.428l8.41 8.139a.592.592 0 0 1 0 .852l-8.5 8.225a.605.605 0 0 0-.183.427c0 .16.066.313.183.426a.635.635 0 0 0 .88-.001l8.5-8.226a.635.635 0 0 1 .88 0l8.488 8.226a.64.64 0 0 0 .887.008.605.605 0 0 0 .182-.43.591.591 0 0 0-.19-.429h-.001Z"
d="M.22.22a.75.75 0 0 1 1.06 0L7 5.94 12.72.22a.75.75 0 1 1 1.06 1.06L8.06 7l5.72 5.72a.75.75 0 1 1-1.06 1.06L7 8.06l-5.72 5.72a.75.75 0 0 1-1.06-1.06L5.94 7 .22 1.28a.75.75 0 0 1 0-1.06Z"
/>
</svg>
`;

View File

@@ -0,0 +1,23 @@
import { css } from "@emotion/css";
import { html } from "lit";
import { IconProps } from "../common-types";
import { buildIconColorRule, ruleNames, themes } from "../constants/styles";
export function CollectionShared({ color, disabled, theme }: IconProps) {
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
return html`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14" fill="none">
<path
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
d="M3.5.75A.75.75 0 0 1 4.25 0h5.5a.75.75 0 0 1 0 1.5h-5.5A.75.75 0 0 1 3.5.75ZM2.25 2a.75.75 0 0 0 0 1.5h9.5a.75.75 0 0 0 0-1.5h-9.5ZM6 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM10 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM7 11.46a1.928 1.928 0 0 0-.586-1.386 2.035 2.035 0 0 0-2.828 0A1.928 1.928 0 0 0 3 11.461c0 .298.241.539.54.539h2.92a.54.54 0 0 0 .54-.54ZM8 11.46a2.928 2.928 0 0 0-.371-1.426A2.005 2.005 0 0 1 9 9.5a2.035 2.035 0 0 1 1.414.574A1.928 1.928 0 0 1 11 11.461a.54.54 0 0 1-.54.539H7.904c.063-.168.097-.35.097-.54Z"
/>
<path
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
fill-rule="evenodd"
d="M12 4a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h10Zm0 1.5a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-.5.5H2a.5.5 0 0 1-.5-.5V6a.5.5 0 0 1 .5-.5h10Z"
/>
</svg>
`;
}

View File

@@ -8,10 +8,20 @@ export function ExclamationTriangle({ color, disabled, theme }: IconProps) {
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
return html`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 22" fill="none">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 15" fill="none">
<path
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
d="M21.627 21.877H2.373a2.28 2.28 0 0 1-1.195-.326 2.394 2.394 0 0 1-.869-.908 2.433 2.433 0 0 1 .015-2.404L9.951 1.33c.211-.368.511-.672.87-.883a2.322 2.322 0 0 1 2.357 0c.36.211.66.515.871.882l9.627 16.911a2.442 2.442 0 0 1 .015 2.404 2.39 2.39 0 0 1-.87.908c-.362.217-.775.33-1.194.326ZM12 1.677a.844.844 0 0 0-.436.115.883.883 0 0 0-.322.326l-9.625 16.91a.846.846 0 0 0 0 .844.876.876 0 0 0 .322.334.84.84 0 0 0 .44.117h19.248a.837.837 0 0 0 .44-.117.882.882 0 0 0 .322-.334.846.846 0 0 0 0-.843L12.758 2.118a.89.89 0 0 0-.322-.326.837.837 0 0 0-.436-.114Zm0 13.309a.735.735 0 0 1-.53-.228.794.794 0 0 1-.22-.55V7.105a.79.79 0 0 1 .22-.55.735.735 0 0 1 1.06 0c.14.146.22.344.22.55v7.105a.79.79 0 0 1-.22.55.74.74 0 0 1-.53.227Zm0 3.84c.491 0 .89-.412.89-.92 0-.51-.399-.922-.89-.922s-.89.412-.89.921c0 .51.399.922.89.922Z"
d="M9 11C9 11.5523 8.55229 12 8 12C7.44772 12 7 11.5523 7 11C7 10.4477 7.44772 10 8 10C8.55229 10 9 10.4477 9 11Z"
/>
<path
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
d="M7.31639 5C7.01564 5 6.78295 5.26359 6.82025 5.56202L7.19525 8.56202C7.22653 8.81223 7.43923 9 7.69139 9H8.30861C8.56077 9 8.77347 8.81223 8.80475 8.56202L9.17975 5.56202C9.21705 5.26359 8.98436 5 8.68361 5H7.31639Z"
/>
<path
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
fill-rule="evenodd"
clip-rule="evenodd"
d="M9.37384 1.01584C8.76324 -0.04174 7.23675 -0.041739 6.62616 1.01584L0.2149 12.1205C-0.395695 13.1781 0.36755 14.5 1.58874 14.5H14.4113C15.6325 14.5 16.3957 13.1781 15.7851 12.1205L9.37384 1.01584ZM14.4861 12.8705L8.0748 1.76584C8.06066 1.74135 8.05029 1.7355 8.04562 1.73291C8.03694 1.7281 8.02122 1.72266 8 1.72266C7.97878 1.72266 7.96305 1.7281 7.95438 1.73291C7.94971 1.7355 7.93934 1.74135 7.9252 1.76584L1.51394 12.8705C1.4998 12.895 1.49992 12.9069 1.50001 12.9122C1.50018 12.9221 1.50333 12.9385 1.51394 12.9568C1.52455 12.9752 1.53713 12.9861 1.54563 12.9912C1.55021 12.994 1.56046 13 1.58874 13H14.4113C14.4395 13 14.4498 12.994 14.4544 12.9912C14.4629 12.9861 14.4754 12.9752 14.4861 12.9568C14.4967 12.9385 14.4998 12.9221 14.5 12.9122C14.5001 12.9069 14.5002 12.895 14.4861 12.8705Z"
/>
</svg>
`;

View File

@@ -8,10 +8,11 @@ export function Family({ color, disabled, theme }: IconProps) {
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
return html`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 18" fill="none">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
<path
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
d="M20.535 8.219a4.5 4.5 0 1 0-5.07 0c-.34.187-.657.414-.945.675a3 3 0 0 0-5.04 0 5.745 5.745 0 0 0-.945-.675 4.5 4.5 0 1 0-5.07 0A7.5 7.5 0 0 0 0 13.829C0 14.34.135 15 .645 15H8.07a6.6 6.6 0 0 0-.57 2.055c0 .405.105.945.48.945h7.83c.48 0 .735-.345.66-.945A7.503 7.503 0 0 0 15.93 15h7.17c.645 0 .975-.42.885-1.17a7.5 7.5 0 0 0-3.45-5.61ZM15 4.499a3 3 0 1 1 6 0 3 3 0 0 1-6 0Zm-3 4.5a1.5 1.5 0 0 1 1.5 1.11c.016.13.016.26 0 .39a1.5 1.5 0 0 1-.99 1.395 1.29 1.29 0 0 1-1.02 0 1.5 1.5 0 0 1-.99-1.395 1.778 1.778 0 0 1 0-.39A1.5 1.5 0 0 1 12 9Zm-9-4.5a3 3 0 1 1 6 0 3 3 0 0 1-6 0Zm-1.425 9C2.13 10.71 4.08 9 6.075 9A4.035 4.035 0 0 1 9 10.499a3 3 0 0 0 .945 2.145A4.499 4.499 0 0 0 9 13.5H1.575Zm13.29 3h-5.73A5.07 5.07 0 0 1 9.75 15 2.865 2.865 0 0 1 12 13.5h.15a2.82 2.82 0 0 1 2.16 1.5c.27.465.457.972.555 1.5Zm.135-3a4.5 4.5 0 0 0-.945-.825A3 3 0 0 0 15 10.5 4.08 4.08 0 0 1 18 9a5.01 5.01 0 0 1 4.41 4.5H15Z"
fill-rule="evenodd"
d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1.5 0a6.47 6.47 0 0 1-.932 3.356 3.732 3.732 0 0 0-1.106-.784 3.547 3.547 0 0 0-.516-.19 2 2 0 1 0-3.444-1.297c-.323-.216-.681-.4-1.069-.536a2.5 2.5 0 1 0-3.065-.155 5.405 5.405 0 0 0-1.59.674 3.912 3.912 0 0 0-.977.893A6.5 6.5 0 1 1 14.5 8ZM2.531 11.514a.75.75 0 0 0 .103-.13c.276-.436.552-.801.942-1.047a3.837 3.837 0 0 1 1.177-.492 5.243 5.243 0 0 1 .845-.095h.007l.022.001h.023c.436 0 .865.07 1.262.205.381.13.733.335 1.037.584.175.143.324.3.448.465l.164.226a4.13 4.13 0 0 0-1.035 1.565 4.407 4.407 0 0 0-.276 1.537c0 .043.004.085.01.125a6.5 6.5 0 0 1-4.729-2.944Zm10.033.964.07.08a6.481 6.481 0 0 1-3.894 1.9.757.757 0 0 0 .01-.125c0-.35.062-.694.181-1.013a2.63 2.63 0 0 1 .505-.842c.213-.237.462-.42.73-.543.267-.123.55-.185.834-.185.284 0 .567.062.835.185.267.123.516.306.729.543ZM7 6.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM11 9a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0Z"
/>
</svg>
`;

View File

@@ -8,10 +8,11 @@ export function Folder({ color, disabled, theme }: IconProps) {
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
return html`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14" fill="none">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 13" fill="none">
<path
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
d="M12.705 2.813h-4.65a.48.48 0 0 1-.34-.155.509.509 0 0 1-.134-.355v-.231a1.354 1.354 0 0 0-.378-.947 1.291 1.291 0 0 0-.92-.397H1.296a1.291 1.291 0 0 0-.919.398A1.354 1.354 0 0 0 0 2.072v9.847c-.002.354.134.694.377.947.244.252.574.394.92.397l11.406.01a1.255 1.255 0 0 0 .907-.384 1.35 1.35 0 0 0 .39-.963V4.158c0-.353-.136-.693-.378-.945a1.296 1.296 0 0 0-.917-.4ZM1.297 1.562h4.988c.13.004.251.06.34.155a.504.504 0 0 1 .134.355v.231c0 .354.136.694.38.946.242.251.573.394.919.398h4.649c.128.004.25.059.34.154.089.096.137.223.134.355v.995a.326.326 0 0 1-.196.296.308.308 0 0 1-.12.024H1.139a.31.31 0 0 1-.223-.093.326.326 0 0 1-.092-.227V2.07a.506.506 0 0 1 .133-.355.479.479 0 0 1 .34-.155Zm11.734 10.735a.456.456 0 0 1-.325.139l-11.409-.008a.48.48 0 0 1-.34-.154.504.504 0 0 1-.133-.355V6.63a.33.33 0 0 1 .092-.227.32.32 0 0 1 .222-.094h11.727a.31.31 0 0 1 .223.094c.06.06.093.142.093.227v5.299a.527.527 0 0 1-.15.367Z"
d="M2 0a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8L6.586.586A2 2 0 0 0 5.172 0H2Zm5.379 3.5L5.525 1.646a.5.5 0 0 0-.353-.146H2a.5.5 0 0 0-.5.5v9a.5.5 0 0 0 .5.5h12a.5.5 0 0 0 .5-.5V4a.5.5 0 0 0-.5-.5H7.379Z"
fill-rule="evenodd"
/>
</svg>
`;

View File

@@ -8,11 +8,10 @@ export function Globe({ color, disabled, theme }: IconProps) {
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
return html`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
<path
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
fill-rule="evenodd"
d="M19.958 18.85A10.476 10.476 0 0 1 12 22.5c-1.674 0-3.256-.392-4.66-1.088l.084-.195c.03-.07.06-.135.084-.194l.086-.2c.11-.262.23-.548.372-.832.18-.36.374-.67.58-.875.035-.035.137-.096.372-.146.225-.049.512-.076.855-.095.187-.01.392-.018.607-.026.542-.02 1.15-.043 1.72-.118.426-.057.769-.169 1.025-.349.27-.19.422-.44.477-.713a1.531 1.531 0 0 0-.04-.697 3.99 3.99 0 0 0-.162-.458l-.014-.037a5.335 5.335 0 0 0-.198-.425c-.17-.34-.321-.64-.39-1.395-.04-.442.122-.939.34-1.463l.121-.283c.069-.158.137-.317.191-.456.086-.22.174-.482.174-.728 0-.242-.077-.5-.18-.731a3.271 3.271 0 0 0-.434-.704c-.335-.416-.863-.86-1.485-.86-.597 0-1.367.217-2.02.473-.334.13-.652.278-.922.425-.262.143-.507.3-.67.46-1.262 1.236-2.01 1.593-2.458 1.619-.376.021-.649-.194-.999-.676-.085-.117-.168-.24-.257-.373l-.01-.014c-.084-.126-.174-.26-.268-.39-.192-.269-.42-.55-.708-.771a1.946 1.946 0 0 0-1.085-.416 2.206 2.206 0 0 0-.393.011 10.477 10.477 0 0 1 2.7-5.06c.074.44.198.804.369 1.1.311.54.762.822 1.258.921.55.11 1.163-.08 1.711-.277.17-.062.337-.126.505-.19.42-.16.84-.321 1.287-.439.35-.092.788-.073 1.3.017.36.062.72.151 1.088.242.16.04.323.08.488.119.515.12 1.063.228 1.542.19.51-.041.996-.254 1.27-.802.171-.343.168-.67.03-.966-.117-.253-.324-.457-.475-.604-.352-.344-.558-.55-.558-.881 0-.161.05-.258.122-.338.086-.095.219-.18.411-.272.08-.039.162-.075.25-.114l.048-.02c.102-.046.214-.097.32-.153.064-.033.133-.072.202-.119 2.628.96 4.765 2.941 5.932 5.462a12.186 12.186 0 0 0-.043-.005c-.514-.06-1.002-.07-1.442.08-.463.158-.82.472-1.111.951a9.428 9.428 0 0 1-.628.862 82.02 82.02 0 0 0-.13.165c-.281.362-.585.765-.788 1.182-.204.42-.332.908-.191 1.418.14.512.524.95 1.136 1.332.134.085.304.145.427.188l.027.01c.146.052.264.095.363.15a.422.422 0 0 1 .17.147c.021.038.05.112.029.264a7.15 7.15 0 0 0-.07 1.33c.022.375.096.759.295 1.04.16.224.162.542.147 1.01v.014c-.007.198-.015.447.029.666.03.149.089.312.203.45Zm1.843.075A11.945 11.945 0 0 0 24 12c0-6.627-5.373-12-12-12S0 5.373 0 12c0 4.495 2.471 8.413 6.13 10.468v.12h.218A11.946 11.946 0 0 0 12 24c4.011 0 7.563-1.968 9.742-4.991a.73.73 0 0 0 .065-.08l-.006-.004Zm-15.253 2.05.059-.135.074-.17.078-.185c.11-.262.246-.584.403-.896.193-.387.439-.803.749-1.111.222-.221.532-.327.818-.388a6.64 6.64 0 0 1 .994-.114c.22-.012.445-.02.672-.029.525-.02 1.062-.039 1.587-.108.346-.046.532-.127.626-.193.081-.057.103-.108.112-.155a.653.653 0 0 0-.027-.285c-.032-.12-.078-.234-.128-.358l-.014-.035a3.953 3.953 0 0 0-.141-.298c-.183-.362-.422-.837-.508-1.776-.063-.685.187-1.366.406-1.892l.137-.32c.062-.143.117-.27.166-.398.085-.218.113-.34.113-.402 0-.066-.025-.192-.102-.364a2.38 2.38 0 0 0-.314-.507c-.283-.352-.58-.524-.783-.524-.427 0-1.07.167-1.692.41-.303.12-.587.251-.82.378-.24.131-.397.242-.47.314-1.271 1.244-2.23 1.827-3.036 1.873-.878.05-1.406-.532-1.78-1.045-.095-.132-.188-.27-.275-.4l-.006-.01c-.087-.13-.17-.252-.255-.371-.176-.245-.344-.442-.527-.583a1.047 1.047 0 0 0-.592-.23 1.54 1.54 0 0 0-.495.058 10.494 10.494 0 0 0 4.971 10.25Zm14.072-2.978c0-.07.002-.15.005-.247l.002-.059c.014-.392.036-1.01-.314-1.502-.05-.07-.111-.244-.13-.572a6.267 6.267 0 0 1 .063-1.158c.042-.314-.005-.595-.142-.833a1.308 1.308 0 0 0-.51-.481 3.197 3.197 0 0 0-.497-.213l-.004-.001a1.74 1.74 0 0 1-.278-.112c-.501-.314-.686-.592-.746-.809-.06-.217-.02-.468.134-.786.156-.321.403-.656.689-1.022l.119-.152.001-.002c.242-.308.5-.636.696-.958.206-.339.41-.491.632-.567.244-.084.566-.094 1.048-.038.192.023.372.05.537.08.373 1.077.575 2.232.575 3.435 0 2.23-.695 4.297-1.88 5.997ZM14.33 1.759C13.58 1.59 12.8 1.5 12 1.5c-2.567 0-4.918.92-6.742 2.45.033.693.163 1.14.325 1.42.177.306.401.438.655.489.272.054.657-.035 1.228-.242.14-.05.29-.107.447-.167.434-.166.925-.354 1.422-.485.541-.143 1.133-.096 1.685 0 .392.068.802.17 1.184.264.153.038.301.074.442.107.52.122.942.195 1.265.17.294-.024.443-.12.537-.307.053-.107.037-.147.02-.185-.038-.08-.12-.175-.289-.34a12.25 12.25 0 0 0-.059-.056c-.295-.285-.77-.743-.77-1.468 0-.393.138-.704.356-.944.186-.204.418-.343.624-.447Z"
d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0Zm0 14.5c.23 0 .843-.226 1.487-1.514.524-1.048.906-2.526.994-4.236H5.519c.088 1.71.47 3.188.994 4.236C7.157 14.274 7.77 14.5 8 14.5ZM5.52 7.25h4.96c-.087-1.71-.47-3.188-.993-4.236C8.843 1.726 8.23 1.5 8 1.5c-.23 0-.843.226-1.487 1.514C5.99 4.062 5.607 5.54 5.52 7.25Zm6.463 0h2.474a6.506 6.506 0 0 0-3.766-5.168c.718 1.305 1.197 3.125 1.292 5.168Zm-7.966 0c.095-2.043.574-3.863 1.292-5.168A6.506 6.506 0 0 0 1.543 7.25h2.474Zm7.966 1.5c-.095 2.043-.574 3.863-1.292 5.168a6.506 6.506 0 0 0 3.766-5.168h-2.474Zm-6.677 5.185c-.718-1.305-1.197-3.125-1.292-5.168H1.54a6.506 6.506 0 0 0 3.766 5.168Z"
/>
</svg>
`;

View File

@@ -3,14 +3,12 @@ export { AngleUp } from "./angle-up";
export { BrandIconContainer } from "./brand-icon-container";
export { Business } from "./business";
export { Close } from "./close";
export { CollectionShared } from "./collection-shared";
export { ExclamationTriangle } from "./exclamation-triangle";
export { ExternalLink } from "./external-link";
export { Family } from "./family";
export { Folder } from "./folder";
export { Globe } from "./globe";
export { Keyhole } from "./keyhole";
export { PartyHorn } from "./party-horn";
export { PencilSquare } from "./pencil-square";
export { Shield } from "./shield";
export { User } from "./user";
export { Warning } from "./warning";

View File

@@ -8,10 +8,14 @@ export function PencilSquare({ color, disabled, theme }: IconProps) {
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
return html`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15" fill="none">
<path
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
d="M17.799 24H2.709a2.422 2.422 0 0 1-1.729-.735 2.533 2.533 0 0 1-.715-1.768V6.03c0-.663.257-1.299.715-1.769a2.416 2.416 0 0 1 1.728-.734h7.996c.216 0 .424.088.577.244a.846.846 0 0 1 0 1.18.808.808 0 0 1-.577.245H2.708a.809.809 0 0 0-.576.244.844.844 0 0 0-.238.59v15.467c0 .221.085.433.238.59.153.156.36.244.576.244h15.09a.809.809 0 0 0 .577-.244.843.843 0 0 0 .238-.59v-6.754a.832.832 0 0 1 .494-.801.796.796 0 0 1 .64 0 .82.82 0 0 1 .442.472.836.836 0 0 1 .052.33v6.753a2.53 2.53 0 0 1-.715 1.768c-.458.47-1.08.734-1.727.735ZM9.24 15.417a.812.812 0 0 1-.677-.373.852.852 0 0 1-.074-.783l1.32-3.239c.121-.297.297-.567.52-.795L19.615.714A2.394 2.394 0 0 1 21.325 0c.638.002 1.25.263 1.703.726.452.463.706 1.09.707 1.744a2.502 2.502 0 0 1-.7 1.746l-9.229 9.455c-.274.28-.609.489-.977.61l-3.34 1.09a.801.801 0 0 1-.248.047Zm12.084-13.76a.771.771 0 0 0-.558.235l-9.282 9.514a.828.828 0 0 0-.17.26l-.642 1.572 1.663-.543a.778.778 0 0 0 .317-.198l9.231-9.455a.812.812 0 0 0 .172-.88.805.805 0 0 0-.29-.363.78.78 0 0 0-.44-.136v-.006h-.001Z"
d="M11.013.677a1.75 1.75 0 0 1 2.474 0l.836.836a1.75 1.75 0 0 1 0 2.475L9.03 9.28a.75.75 0 0 1-.348.197l-3 .75a.75.75 0 0 1-.91-.91l.75-3a.75.75 0 0 1 .198-.348L11.013.677Zm1.414 1.06a.25.25 0 0 0-.354 0l-.646.647a.75.75 0 0 1 .103.086l1 1a.751.751 0 0 1 .087.103l.646-.646a.25.25 0 0 0 0-.353l-.836-.836Zm-.854 2.88a.752.752 0 0 1-.103-.087l-1-1a.756.756 0 0 1-.087-.103L6.928 6.884 6.531 8.47l1.586-.397 3.456-3.456Z"
/>
<path
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
d="M2.75 2.5c-.69 0-1.25.56-1.25 1.25v8.5c0 .69.56 1.25 1.25 1.25h8.5c.69 0 1.25-.56 1.25-1.25v-3.5a.75.75 0 0 1 1.5 0v3.5A2.75 2.75 0 0 1 11.25 15h-8.5A2.75 2.75 0 0 1 0 12.25v-8.5A2.75 2.75 0 0 1 2.75 1h3.5a.75.75 0 0 1 0 1.5h-3.5Z"
/>
</svg>
`;

View File

@@ -8,10 +8,10 @@ export function Shield({ color, theme }: IconProps) {
const shapeColor = color || themes[theme].brandLogo;
return html`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 24" fill="none">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 16" fill="none">
<path
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
d="M19.703.3A.97.97 0 0 0 19 0H1a.958.958 0 0 0-.702.3.962.962 0 0 0-.3.702v12c.004.913.18 1.818.522 2.665a9.95 9.95 0 0 0 1.297 2.345c.552.72 1.169 1.387 1.844 1.993a21.721 21.721 0 0 0 1.975 1.61c.6.426 1.23.83 1.89 1.21.66.381 1.126.639 1.398.773.275.135.497.241.662.312.129.062.27.093.414.09a.87.87 0 0 0 .406-.095c.168-.073.387-.177.665-.312.277-.135.75-.393 1.398-.772.648-.38 1.285-.785 1.89-1.21.69-.499 1.35-1.036 1.978-1.61a14.458 14.458 0 0 0 1.844-1.994c.535-.72.972-1.508 1.297-2.344a7.185 7.185 0 0 0 .522-2.666v-12A.944.944 0 0 0 19.703.3Zm-2.32 12.811c0 4.35-7.382 8.087-7.382 8.087V2.57h7.381v10.54Z"
d="M13.469.2A.647.647 0 0 0 13 0H1a.639.639 0 0 0-.468.2.641.641 0 0 0-.2.468v8a4.81 4.81 0 0 0 .348 1.777c.216.557.507 1.083.865 1.563.367.48.779.925 1.229 1.329.417.383.857.741 1.317 1.073.4.284.82.553 1.26.807.44.254.75.425.932.515.183.09.33.16.44.208.087.041.181.062.277.06a.58.58 0 0 0 .27-.063c.113-.05.259-.118.444-.208s.5-.262.932-.515c.432-.253.857-.523 1.26-.807.46-.332.9-.69 1.319-1.073.45-.404.861-.849 1.228-1.33.357-.48.648-1.005.865-1.562a4.79 4.79 0 0 0 .348-1.777v-8A.63.63 0 0 0 13.47.2Zm-1.547 8.54c0 2.9-4.921 5.392-4.921 5.392V1.714h4.92v7.027Z"
/>
</svg>
`;

View File

@@ -8,10 +8,10 @@ export function User({ color, disabled, theme }: IconProps) {
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
return html`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14" fill="none">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 15" fill="none">
<path
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
d="M13.51 12.189a6.616 6.616 0 0 0-4.524-4.915 3.87 3.87 0 0 0 1.968-3.348 3.926 3.926 0 1 0-7.852 0 3.864 3.864 0 0 0 1.95 3.338A6.61 6.61 0 0 0 .49 12.189 1.499 1.499 0 0 0 1.962 14h10.077a1.503 1.503 0 0 0 1.471-1.812ZM4.044 3.926A2.987 2.987 0 0 1 7.592.963a2.985 2.985 0 0 1-.563 5.916 2.973 2.973 0 0 1-2.985-2.953Zm8.427 8.938a.548.548 0 0 1-.436.204H1.962a.548.548 0 0 1-.432-.204.576.576 0 0 1-.119-.486 5.724 5.724 0 0 1 9.175-3.23 5.723 5.723 0 0 1 2.003 3.23.571.571 0 0 1-.118.486Z"
d="M9.203 7.339a4 4 0 1 0-4.407 0A7.033 7.033 0 0 0 2.05 8.953a6.655 6.655 0 0 0-1.517 2.162A6.393 6.393 0 0 0 0 13.667C0 14.403.597 15 1.333 15h11.334c.736 0 1.333-.597 1.333-1.333 0-.876-.181-1.743-.533-2.552a6.654 6.654 0 0 0-1.517-2.162 7.032 7.032 0 0 0-2.747-1.614ZM9.5 4a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0Zm2.592 7.714c.247.57.384 1.175.405 1.786H1.503a4.897 4.897 0 0 1 .405-1.786 5.156 5.156 0 0 1 1.177-1.675 5.534 5.534 0 0 1 1.787-1.136A5.805 5.805 0 0 1 7 8.5c.732 0 1.456.137 2.128.403.673.265 1.28.652 1.787 1.136a5.156 5.156 0 0 1 1.177 1.675Z"
/>
</svg>
`;

View File

@@ -1,23 +0,0 @@
import { html } from "lit";
// This icon has static multi-colors for each theme
export function Warning() {
return html`
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 40 36">
<path
fill="#FFBF00"
d="M15.944 2.483c1.81-3.111 6.303-3.111 8.111 0l15.302 26.319c1.819 3.127-.438 7.049-4.055 7.049H4.698c-3.617 0-5.874-3.922-4.055-7.05L15.944 2.484Z"
/>
<path
fill="#0E3781"
fill-rule="evenodd"
d="M37.735 29.745 22.433 3.425c-1.085-1.866-3.781-1.866-4.866 0L2.265 29.746c-1.091 1.876.263 4.23 2.433 4.23h30.604c2.17 0 3.524-2.354 2.433-4.23ZM24.055 2.483c-1.808-3.111-6.302-3.111-8.11 0L.643 28.802c-1.819 3.127.438 7.049 4.055 7.049h30.604c3.617 0 5.874-3.922 4.055-7.05L24.055 2.484Z"
clip-rule="evenodd"
/>
<path
fill="#0E3781"
d="M21.876 28.345a1.876 1.876 0 1 1-3.752 0 1.876 1.876 0 0 1 3.752 0ZM17.24 11.976a.47.47 0 0 1 .467-.519h4.586c.279 0 .496.242.466.52l-1.307 12.196a.47.47 0 0 1-.466.42h-1.972a.47.47 0 0 1-.466-.42L17.24 11.976Z"
/>
</svg>
`;
}

View File

@@ -5,16 +5,10 @@ import { ThemeTypes } from "@bitwarden/common/platform/enums";
import { IconProps } from "../common-types";
// This icon has static multi-colors for each theme
export function PartyHorn({ theme }: IconProps) {
export function Celebrate({ theme }: IconProps) {
if (theme === ThemeTypes.Dark) {
return html`
<svg
xmlns="http://www.w3.org/2000/svg"
width="50"
height="50"
viewBox="0 0 50 50"
fill="none"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" fill="none">
<path
d="M32.6273 37.2714L3.88045 49.2492C2.98525 49.6222 1.95344 49.4181 1.26769 48.7323C0.581933 48.0466 0.377816 47.0148 0.750816 46.1196L12.7287 17.3728C13.622 15.2288 15.9911 14.1069 18.2158 14.7743L19.0257 15.0173C26.6887 17.3161 32.6839 23.3113 34.9828 30.9743L35.2257 31.7842C35.8931 34.0089 34.7712 36.3781 32.6273 37.2714Z"
fill="#FFBF00"
@@ -142,7 +136,7 @@ export function PartyHorn({ theme }: IconProps) {
}
return html`
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="none">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" fill="none">
<path
d="M32.6275 37.2714L3.88069 49.2492C2.98549 49.6222 1.95368 49.4181 1.26793 48.7323C0.582178 48.0466 0.37806 47.0148 0.751061 46.1196L12.7289 17.3728C13.6222 15.2288 15.9914 14.1069 18.216 14.7743L19.026 15.0173C26.6889 17.3161 32.6841 23.3113 34.983 30.9743L35.226 31.7842C35.8934 34.0089 34.7714 36.3781 32.6275 37.2714Z"
fill="#FFBF00"

View File

@@ -0,0 +1,3 @@
export { Celebrate } from "./celebrate";
export { Keyhole } from "./keyhole";
export { Warning } from "./warning";

View File

@@ -0,0 +1,22 @@
import { html } from "lit";
// This icon has static multi-colors for each theme
export function Warning() {
return html`
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 18">
<path
fill="#FFBF00"
d="M11.717.999a1.975 1.975 0 0 0-3.434 0l-8.014 14c-.764 1.333.19 3 1.717 3h16.028c1.527 0 2.48-1.667 1.717-3zm6.713 14.745-8.014-14a.475.475 0 0 0-.832 0l-8.014 14a.5.5 0 0 0 .416.755h16.028a.5.5 0 0 0 .416-.755z"
/>
<path
fill="#0E3781"
fill-rule="evenodd"
d="M11.717 1a1.975 1.975 0 0 0-3.434 0L.269 15c-.764 1.333.19 3 1.717 3h16.028c1.527 0 2.48-1.667 1.717-3L11.717 1Zm6.713 14.745-8.014-14a.475.475 0 0 0-.832 0l-8.014 14a.5.5 0 0 0 .416.755h16.028a.5.5 0 0 0 .416-.755Z"
/>
<path
fill="#0E3781"
d="M11.25 13.587c0 .697-.56 1.261-1.25 1.261s-1.25-.564-1.25-1.26c0-.697.56-1.261 1.25-1.261s1.25.564 1.25 1.26ZM9.003 6.023a.5.5 0 0 0-.496.561l.501 4.043a.5.5 0 0 0 .496.439h.992a.5.5 0 0 0 .496-.439l.5-4.043a.5.5 0 0 0-.495-.561H9.003Z"
/>
</svg>
`;
}

View File

@@ -2,7 +2,7 @@ import { Meta, Controls } from "@storybook/addon-docs";
import * as stories from "./icons.lit-stories";
<Meta title="Components/Icons/Icons" of={stories} />
<Meta title="Components/Icons" of={stories} />
## Icon Stories
@@ -14,12 +14,12 @@ like size, color, and theme. Each story is an example of how a specific icon can
| | |
| ------------------------- | ------------------ |
| `AngleDownIcon` | `FolderIcon` |
| `BusinessIcon` | `GlobeIcon` |
| `BrandIcon` | `PartyHornIcon` |
| `AngleDownIcon` | `AngleUpIcon` |
| `BusinessIcon` | `FolderIcon` |
| `BrandIcon` | `GlobeIcon` |
| `CloseIcon` | `PencilSquareIcon` |
| `ExclamationTriangleIcon` | `ShieldIcon` |
| `FamilyIcon` | `UserIcon` |
| `UsersIcon` | `UserIcon` |
## Props

View File

@@ -12,7 +12,7 @@ type Args = {
};
export default {
title: "Components/Ciphers/Cipher Indicator Icon",
title: "Components/Ciphers/Cipher Indicator Icons",
argTypes: {
showBusinessIcon: { control: "boolean" },
showFamilyIcon: { control: "boolean" },

View File

@@ -14,7 +14,7 @@ type Args = {
};
export default {
title: "Components/Icons/Icons",
title: "Components/Icons",
argTypes: {
iconLink: { control: "text" },
color: { control: "color" },
@@ -43,26 +43,23 @@ const createIconStory = (iconName: keyof typeof Icons): StoryObj<Args> => {
render: (args) => Template(args, Icons[iconName]),
} as StoryObj<Args>;
if (iconName !== "BrandIconContainer") {
story.argTypes = {
iconLink: { table: { disable: true } },
};
}
story.argTypes = {
iconLink: { table: { disable: true } },
};
return story;
};
export const AngleDownIcon = createIconStory("AngleDown");
export const AngleUpIcon = createIconStory("AngleUp");
export const BusinessIcon = createIconStory("Business");
export const BrandIcon = createIconStory("BrandIconContainer");
export const CloseIcon = createIconStory("Close");
export const CollectionSharedIcon = createIconStory("CollectionShared");
export const ExclamationTriangleIcon = createIconStory("ExclamationTriangle");
export const ExternalLinkIcon = createIconStory("ExternalLink");
export const FamilyIcon = createIconStory("Family");
export const FolderIcon = createIconStory("Folder");
export const GlobeIcon = createIconStory("Globe");
export const KeyholeIcon = createIconStory("Keyhole");
export const PartyHornIcon = createIconStory("PartyHorn");
export const PencilSquareIcon = createIconStory("PencilSquare");
export const ShieldIcon = createIconStory("Shield");
export const UserIcon = createIconStory("User");

View File

@@ -0,0 +1,44 @@
import { Meta, StoryObj } from "@storybook/web-components";
import { html } from "lit";
import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum";
import * as Illustrations from "../../illustrations";
type Args = {
theme: Theme;
size: number;
};
export default {
title: "Components/Illustrations",
argTypes: {
theme: { control: "select", options: [...Object.values(ThemeTypes)] },
size: { control: "number", min: 10, max: 100, step: 1 },
},
args: {
theme: ThemeTypes.Light,
size: 50,
},
} as Meta<Args>;
const Template = (
args: Args,
IllustrationComponent: (props: Args) => ReturnType<typeof html>,
) => html`
<div
style="width: ${args.size}px; height: ${args.size}px; display: flex; align-items: center; justify-content: center;"
>
${IllustrationComponent({ ...args })}
</div>
`;
const createIllustrationStory = (illustrationName: keyof typeof Illustrations): StoryObj<Args> => {
return {
render: (args) => Template(args, Illustrations[illustrationName]),
} as StoryObj<Args>;
};
export const KeyholeIllustration = createIllustrationStory("Keyhole");
export const CelebrateIllustration = createIllustrationStory("Celebrate");
export const WarningIllustration = createIllustrationStory("Warning");

View File

@@ -4,7 +4,7 @@ import { html, nothing } from "lit";
import { Theme } from "@bitwarden/common/platform/enums";
import { themes } from "../../constants/styles";
import { PartyHorn, Keyhole, Warning } from "../../icons";
import { Celebrate, Keyhole, Warning } from "../../illustrations";
import { NotificationConfirmationMessage } from "./message";
@@ -33,7 +33,7 @@ export function NotificationConfirmationBody({
theme,
handleOpenVault,
}: NotificationConfirmationBodyProps) {
const IconComponent = tasksAreComplete ? Keyhole : !error ? PartyHorn : Warning;
const IconComponent = tasksAreComplete ? Keyhole : !error ? Celebrate : Warning;
const showConfirmationMessage = confirmationMessage || buttonText || messageDetails;

View File

@@ -49,7 +49,7 @@ const notificationHeaderStyles = ({
display: flex;
align-items: center;
justify-content: flex-start;
background-color: ${themes[theme].background};
background-color: ${themes[theme].background.DEFAULT};
padding: 12px 16px 8px 16px;
white-space: nowrap;

View File

@@ -62,14 +62,15 @@ const optionItemStyles = css`
`;
const optionItemIconContainerStyles = css`
display: flex;
flex-grow: 1;
flex-shrink: 1;
width: ${optionItemIconWidth}px;
height: ${optionItemIconWidth}px;
max-width: ${optionItemIconWidth}px;
max-height: ${optionItemIconWidth}px;
> svg {
width: 100%;
height: fit-content;
height: auto;
}
`;

View File

@@ -1,124 +0,0 @@
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { LockedVaultPendingNotificationsData } from "../../../background/abstractions/notification.background";
import AutofillPageDetails from "../../../models/autofill-page-details";
type WebsiteIconData = {
imageEnabled: boolean;
image: string;
fallbackImage: string;
icon: string;
};
type OverlayAddNewItemMessage = {
login?: {
uri?: string;
hostname: string;
username: string;
password: string;
};
};
type OverlayBackgroundExtensionMessage = {
[key: string]: any;
command: string;
tab?: chrome.tabs.Tab;
sender?: string;
details?: AutofillPageDetails;
overlayElement?: string;
display?: string;
data?: LockedVaultPendingNotificationsData;
} & OverlayAddNewItemMessage;
type OverlayPortMessage = {
[key: string]: any;
command: string;
direction?: string;
overlayCipherId?: string;
};
type FocusedFieldData = {
focusedFieldStyles: Partial<CSSStyleDeclaration>;
focusedFieldRects: Partial<DOMRect>;
tabId?: number;
};
type OverlayCipherData = {
id: string;
name: string;
type: CipherType;
reprompt: CipherRepromptType;
favorite: boolean;
icon: { imageEnabled: boolean; image: string; fallbackImage: string; icon: string };
login?: { username: string };
card?: string;
};
type BackgroundMessageParam = {
message: OverlayBackgroundExtensionMessage;
};
type BackgroundSenderParam = {
sender: chrome.runtime.MessageSender;
};
type BackgroundOnMessageHandlerParams = BackgroundMessageParam & BackgroundSenderParam;
type OverlayBackgroundExtensionMessageHandlers = {
[key: string]: CallableFunction;
openAutofillOverlay: () => void;
autofillOverlayElementClosed: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
autofillOverlayAddNewVaultItem: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
getAutofillOverlayVisibility: () => void;
checkAutofillOverlayFocused: () => void;
focusAutofillOverlayList: () => void;
updateAutofillOverlayPosition: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
updateAutofillOverlayHidden: ({ message }: BackgroundMessageParam) => void;
updateFocusedFieldData: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
collectPageDetailsResponse: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
unlockCompleted: ({ message }: BackgroundMessageParam) => void;
addedCipher: () => void;
addEditCipherSubmitted: () => void;
editedCipher: () => void;
deletedCipher: () => void;
};
type PortMessageParam = {
message: OverlayPortMessage;
};
type PortConnectionParam = {
port: chrome.runtime.Port;
};
type PortOnMessageHandlerParams = PortMessageParam & PortConnectionParam;
type OverlayButtonPortMessageHandlers = {
[key: string]: CallableFunction;
overlayButtonClicked: ({ port }: PortConnectionParam) => void;
closeAutofillOverlay: ({ port }: PortConnectionParam) => void;
forceCloseAutofillOverlay: ({ port }: PortConnectionParam) => void;
overlayPageBlurred: () => void;
redirectOverlayFocusOut: ({ message, port }: PortOnMessageHandlerParams) => void;
};
type OverlayListPortMessageHandlers = {
[key: string]: CallableFunction;
checkAutofillOverlayButtonFocused: () => void;
forceCloseAutofillOverlay: ({ port }: PortConnectionParam) => void;
overlayPageBlurred: () => void;
unlockVault: ({ port }: PortConnectionParam) => void;
fillSelectedListItem: ({ message, port }: PortOnMessageHandlerParams) => void;
addNewVaultItem: ({ port }: PortConnectionParam) => void;
viewSelectedCipher: ({ message, port }: PortOnMessageHandlerParams) => void;
redirectOverlayFocusOut: ({ message, port }: PortOnMessageHandlerParams) => void;
};
export {
WebsiteIconData,
OverlayBackgroundExtensionMessage,
OverlayPortMessage,
FocusedFieldData,
OverlayCipherData,
OverlayAddNewItemMessage,
OverlayBackgroundExtensionMessageHandlers,
OverlayButtonPortMessageHandlers,
OverlayListPortMessageHandlers,
};

View File

@@ -1,811 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom, map } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { SHOW_AUTOFILL_BUTTON } from "@bitwarden/common/autofill/constants";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { buildCipherIcon } from "@bitwarden/common/vault/icon/build-cipher-icon";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { openUnlockPopout } from "../../../auth/popup/utils/auth-popout-window";
import { BrowserApi } from "../../../platform/browser/browser-api";
import {
openViewVaultItemPopout,
openAddEditVaultItemPopout,
} from "../../../vault/popup/utils/vault-popout-window";
import { LockedVaultPendingNotificationsData } from "../../background/abstractions/notification.background";
import { OverlayBackground as OverlayBackgroundInterface } from "../../background/abstractions/overlay.background";
import { AutofillOverlayElement, AutofillOverlayPort } from "../../enums/autofill-overlay.enum";
import { AutofillService, PageDetail } from "../../services/abstractions/autofill.service";
import {
FocusedFieldData,
OverlayBackgroundExtensionMessageHandlers,
OverlayButtonPortMessageHandlers,
OverlayCipherData,
OverlayListPortMessageHandlers,
OverlayBackgroundExtensionMessage,
OverlayAddNewItemMessage,
OverlayPortMessage,
WebsiteIconData,
} from "./abstractions/overlay.background.deprecated";
class LegacyOverlayBackground implements OverlayBackgroundInterface {
private readonly openUnlockPopout = openUnlockPopout;
private readonly openViewVaultItemPopout = openViewVaultItemPopout;
private readonly openAddEditVaultItemPopout = openAddEditVaultItemPopout;
private overlayLoginCiphers: Map<string, CipherView> = new Map();
private pageDetailsForTab: Record<
chrome.runtime.MessageSender["tab"]["id"],
Map<chrome.runtime.MessageSender["frameId"], PageDetail>
> = {};
private userAuthStatus: AuthenticationStatus = AuthenticationStatus.LoggedOut;
private overlayButtonPort: chrome.runtime.Port;
private overlayListPort: chrome.runtime.Port;
private expiredPorts: chrome.runtime.Port[] = [];
private focusedFieldData: FocusedFieldData;
private overlayPageTranslations: Record<string, string>;
private iconsServerUrl: string;
private readonly extensionMessageHandlers: OverlayBackgroundExtensionMessageHandlers = {
openAutofillOverlay: () => this.openOverlay(false),
autofillOverlayElementClosed: ({ message, sender }) =>
this.overlayElementClosed(message, sender),
autofillOverlayAddNewVaultItem: ({ message, sender }) => this.addNewVaultItem(message, sender),
getAutofillOverlayVisibility: () => this.getOverlayVisibility(),
checkAutofillOverlayFocused: () => this.checkOverlayFocused(),
focusAutofillOverlayList: () => this.focusOverlayList(),
updateAutofillOverlayPosition: ({ message, sender }) =>
this.updateOverlayPosition(message, sender),
updateAutofillOverlayHidden: ({ message }) => this.updateOverlayHidden(message),
updateFocusedFieldData: ({ message, sender }) => this.setFocusedFieldData(message, sender),
collectPageDetailsResponse: ({ message, sender }) => this.storePageDetails(message, sender),
unlockCompleted: ({ message }) => this.unlockCompleted(message),
addedCipher: () => this.updateOverlayCiphers(),
addEditCipherSubmitted: () => this.updateOverlayCiphers(),
editedCipher: () => this.updateOverlayCiphers(),
deletedCipher: () => this.updateOverlayCiphers(),
};
private readonly overlayButtonPortMessageHandlers: OverlayButtonPortMessageHandlers = {
overlayButtonClicked: ({ port }) => this.handleOverlayButtonClicked(port),
closeAutofillOverlay: ({ port }) => this.closeOverlay(port),
forceCloseAutofillOverlay: ({ port }) => this.closeOverlay(port, true),
overlayPageBlurred: () => this.checkOverlayListFocused(),
redirectOverlayFocusOut: ({ message, port }) => this.redirectOverlayFocusOut(message, port),
};
private readonly overlayListPortMessageHandlers: OverlayListPortMessageHandlers = {
checkAutofillOverlayButtonFocused: () => this.checkOverlayButtonFocused(),
forceCloseAutofillOverlay: ({ port }) => this.closeOverlay(port, true),
overlayPageBlurred: () => this.checkOverlayButtonFocused(),
unlockVault: ({ port }) => this.unlockVault(port),
fillSelectedListItem: ({ message, port }) => this.fillSelectedOverlayListItem(message, port),
addNewVaultItem: ({ port }) => this.getNewVaultItemDetails(port),
viewSelectedCipher: ({ message, port }) => this.viewSelectedCipher(message, port),
redirectOverlayFocusOut: ({ message, port }) => this.redirectOverlayFocusOut(message, port),
};
constructor(
private cipherService: CipherService,
private autofillService: AutofillService,
private authService: AuthService,
private environmentService: EnvironmentService,
private domainSettingsService: DomainSettingsService,
private autofillSettingsService: AutofillSettingsServiceAbstraction,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private themeStateService: ThemeStateService,
private accountService: AccountService,
) {}
/**
* Removes cached page details for a tab
* based on the passed tabId.
*
* @param tabId - Used to reference the page details of a specific tab
*/
removePageDetails(tabId: number) {
if (!this.pageDetailsForTab[tabId]) {
return;
}
this.pageDetailsForTab[tabId].clear();
delete this.pageDetailsForTab[tabId];
}
/**
* Sets up the extension message listeners and gets the settings for the
* overlay's visibility and the user's authentication status.
*/
async init() {
this.setupExtensionMessageListeners();
const env = await firstValueFrom(this.environmentService.environment$);
this.iconsServerUrl = env.getIconsUrl();
await this.getOverlayVisibility();
await this.getAuthStatus();
}
/**
* Updates the overlay list's ciphers and sends the updated list to the overlay list iframe.
* Queries all ciphers for the given url, and sorts them by last used. Will not update the
* list of ciphers if the extension is not unlocked.
*/
async updateOverlayCiphers() {
const authStatus = await firstValueFrom(this.authService.activeAccountStatus$);
if (authStatus !== AuthenticationStatus.Unlocked) {
return;
}
const currentTab = await BrowserApi.getTabFromCurrentWindowId();
if (!currentTab?.url) {
return;
}
this.overlayLoginCiphers = new Map();
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const ciphersViews = (
await this.cipherService.getAllDecryptedForUrl(currentTab.url, activeUserId)
).sort((a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b));
for (let cipherIndex = 0; cipherIndex < ciphersViews.length; cipherIndex++) {
this.overlayLoginCiphers.set(`overlay-cipher-${cipherIndex}`, ciphersViews[cipherIndex]);
}
const ciphers = await this.getOverlayCipherData();
this.overlayListPort?.postMessage({ command: "updateOverlayListCiphers", ciphers });
await BrowserApi.tabSendMessageData(currentTab, "updateIsOverlayCiphersPopulated", {
isOverlayCiphersPopulated: Boolean(ciphers.length),
});
}
/**
* Strips out unnecessary data from the ciphers and returns an array of
* objects that contain the cipher data needed for the overlay list.
*/
private async getOverlayCipherData(): Promise<OverlayCipherData[]> {
const showFavicons = await firstValueFrom(this.domainSettingsService.showFavicons$);
const overlayCiphersArray = Array.from(this.overlayLoginCiphers);
const overlayCipherData: OverlayCipherData[] = [];
let loginCipherIcon: WebsiteIconData;
for (let cipherIndex = 0; cipherIndex < overlayCiphersArray.length; cipherIndex++) {
const [overlayCipherId, cipher] = overlayCiphersArray[cipherIndex];
if (!loginCipherIcon && cipher.type === CipherType.Login) {
loginCipherIcon = buildCipherIcon(this.iconsServerUrl, cipher, showFavicons);
}
overlayCipherData.push({
id: overlayCipherId,
name: cipher.name,
type: cipher.type,
reprompt: cipher.reprompt,
favorite: cipher.favorite,
icon:
cipher.type === CipherType.Login
? loginCipherIcon
: buildCipherIcon(this.iconsServerUrl, cipher, showFavicons),
login: cipher.type === CipherType.Login ? { username: cipher.login.username } : null,
card: cipher.type === CipherType.Card ? cipher.card.subTitle : null,
});
}
return overlayCipherData;
}
/**
* Handles aggregation of page details for a tab. Stores the page details
* in association with the tabId of the tab that sent the message.
*
* @param message - Message received from the `collectPageDetailsResponse` command
* @param sender - The sender of the message
*/
private storePageDetails(
message: OverlayBackgroundExtensionMessage,
sender: chrome.runtime.MessageSender,
) {
const pageDetails = {
frameId: sender.frameId,
tab: sender.tab,
details: message.details,
};
const pageDetailsMap = this.pageDetailsForTab[sender.tab.id];
if (!pageDetailsMap) {
this.pageDetailsForTab[sender.tab.id] = new Map([[sender.frameId, pageDetails]]);
return;
}
pageDetailsMap.set(sender.frameId, pageDetails);
}
/**
* Triggers autofill for the selected cipher in the overlay list. Also places
* the selected cipher at the top of the list of ciphers.
*
* @param overlayCipherId - Cipher ID corresponding to the overlayLoginCiphers map. Does not correspond to the actual cipher's ID.
* @param sender - The sender of the port message
*/
private async fillSelectedOverlayListItem(
{ overlayCipherId }: OverlayPortMessage,
{ sender }: chrome.runtime.Port,
) {
const pageDetails = this.pageDetailsForTab[sender.tab.id];
if (!overlayCipherId || !pageDetails?.size) {
return;
}
const cipher = this.overlayLoginCiphers.get(overlayCipherId);
if (await this.autofillService.isPasswordRepromptRequired(cipher, sender.tab)) {
return;
}
const totpCode = await this.autofillService.doAutoFill({
tab: sender.tab,
cipher: cipher,
pageDetails: Array.from(pageDetails.values()),
fillNewPassword: true,
allowTotpAutofill: true,
});
if (totpCode) {
this.platformUtilsService.copyToClipboard(totpCode);
}
this.overlayLoginCiphers = new Map([[overlayCipherId, cipher], ...this.overlayLoginCiphers]);
}
/**
* Checks if the overlay is focused. Will check the overlay list
* if it is open, otherwise it will check the overlay button.
*/
private checkOverlayFocused() {
if (this.overlayListPort) {
this.checkOverlayListFocused();
return;
}
this.checkOverlayButtonFocused();
}
/**
* Posts a message to the overlay button iframe to check if it is focused.
*/
private checkOverlayButtonFocused() {
this.overlayButtonPort?.postMessage({ command: "checkAutofillOverlayButtonFocused" });
}
/**
* Posts a message to the overlay list iframe to check if it is focused.
*/
private checkOverlayListFocused() {
this.overlayListPort?.postMessage({ command: "checkAutofillOverlayListFocused" });
}
/**
* Sends a message to the sender tab to close the autofill overlay.
*
* @param sender - The sender of the port message
* @param forceCloseOverlay - Identifies whether the overlay should be force closed
*/
private closeOverlay({ sender }: chrome.runtime.Port, forceCloseOverlay = false) {
// 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.tabSendMessageData(sender.tab, "closeAutofillOverlay", { forceCloseOverlay });
}
/**
* Handles cleanup when an overlay element is closed. Disconnects
* the list and button ports and sets them to null.
*
* @param overlayElement - The overlay element that was closed, either the list or button
* @param sender - The sender of the port message
*/
private overlayElementClosed(
{ overlayElement }: OverlayBackgroundExtensionMessage,
sender: chrome.runtime.MessageSender,
) {
if (sender.tab.id !== this.focusedFieldData?.tabId) {
this.expiredPorts.forEach((port) => port.disconnect());
this.expiredPorts = [];
return;
}
if (overlayElement === AutofillOverlayElement.Button) {
this.overlayButtonPort?.disconnect();
this.overlayButtonPort = null;
return;
}
this.overlayListPort?.disconnect();
this.overlayListPort = null;
}
/**
* Updates the position of either the overlay list or button. The position
* is based on the focused field's position and dimensions.
*
* @param overlayElement - The overlay element to update, either the list or button
* @param sender - The sender of the port message
*/
private updateOverlayPosition(
{ overlayElement }: { overlayElement?: string },
sender: chrome.runtime.MessageSender,
) {
if (!overlayElement || sender.tab.id !== this.focusedFieldData?.tabId) {
return;
}
if (overlayElement === AutofillOverlayElement.Button) {
this.overlayButtonPort?.postMessage({
command: "updateIframePosition",
styles: this.getOverlayButtonPosition(),
});
return;
}
this.overlayListPort?.postMessage({
command: "updateIframePosition",
styles: this.getOverlayListPosition(),
});
}
/**
* Gets the position of the focused field and calculates the position
* of the overlay button based on the focused field's position and dimensions.
*/
private getOverlayButtonPosition() {
if (!this.focusedFieldData) {
return;
}
const { top, left, width, height } = this.focusedFieldData.focusedFieldRects;
const { paddingRight, paddingLeft } = this.focusedFieldData.focusedFieldStyles;
let elementOffset = height * 0.37;
if (height >= 35) {
elementOffset = height >= 50 ? height * 0.47 : height * 0.42;
}
const elementHeight = height - elementOffset;
const elementTopPosition = top + elementOffset / 2;
let elementLeftPosition = left + width - height + elementOffset / 2;
const fieldPaddingRight = parseInt(paddingRight, 10);
const fieldPaddingLeft = parseInt(paddingLeft, 10);
if (fieldPaddingRight > fieldPaddingLeft) {
elementLeftPosition = left + width - height - (fieldPaddingRight - elementOffset + 2);
}
return {
top: `${Math.round(elementTopPosition)}px`,
left: `${Math.round(elementLeftPosition)}px`,
height: `${Math.round(elementHeight)}px`,
width: `${Math.round(elementHeight)}px`,
};
}
/**
* Gets the position of the focused field and calculates the position
* of the overlay list based on the focused field's position and dimensions.
*/
private getOverlayListPosition() {
if (!this.focusedFieldData) {
return;
}
const { top, left, width, height } = this.focusedFieldData.focusedFieldRects;
return {
width: `${Math.round(width)}px`,
top: `${Math.round(top + height)}px`,
left: `${Math.round(left)}px`,
};
}
/**
* Sets the focused field data to the data passed in the extension message.
*
* @param focusedFieldData - Contains the rects and styles of the focused field.
* @param sender - The sender of the extension message
*/
private setFocusedFieldData(
{ focusedFieldData }: OverlayBackgroundExtensionMessage,
sender: chrome.runtime.MessageSender,
) {
this.focusedFieldData = { ...focusedFieldData, tabId: sender.tab.id };
}
/**
* Updates the overlay's visibility based on the display property passed in the extension message.
*
* @param display - The display property of the overlay, either "block" or "none"
*/
private updateOverlayHidden({ display }: OverlayBackgroundExtensionMessage) {
if (!display) {
return;
}
const portMessage = { command: "updateOverlayHidden", styles: { display } };
this.overlayButtonPort?.postMessage(portMessage);
this.overlayListPort?.postMessage(portMessage);
}
/**
* Sends a message to the currently active tab to open the autofill overlay.
*
* @param isFocusingFieldElement - Identifies whether the field element should be focused when the overlay is opened
* @param isOpeningFullOverlay - Identifies whether the full overlay should be forced open regardless of other states
*/
private async openOverlay(isFocusingFieldElement = false, isOpeningFullOverlay = false) {
const currentTab = await BrowserApi.getTabFromCurrentWindowId();
await BrowserApi.tabSendMessageData(currentTab, "openAutofillOverlay", {
isFocusingFieldElement,
isOpeningFullOverlay,
authStatus: await this.getAuthStatus(),
});
}
/**
* Gets the overlay's visibility setting from the settings service.
*/
private async getOverlayVisibility(): Promise<InlineMenuVisibilitySetting> {
return await firstValueFrom(this.autofillSettingsService.inlineMenuVisibility$);
}
/**
* Gets the user's authentication status from the auth service. If the user's
* authentication status has changed, the overlay button's authentication status
* will be updated and the overlay list's ciphers will be updated.
*/
private async getAuthStatus() {
const formerAuthStatus = this.userAuthStatus;
this.userAuthStatus = await this.authService.getAuthStatus();
if (
this.userAuthStatus !== formerAuthStatus &&
this.userAuthStatus === AuthenticationStatus.Unlocked
) {
this.updateOverlayButtonAuthStatus();
await this.updateOverlayCiphers();
}
return this.userAuthStatus;
}
/**
* Sends a message to the overlay button to update its authentication status.
*/
private updateOverlayButtonAuthStatus() {
this.overlayButtonPort?.postMessage({
command: "updateOverlayButtonAuthStatus",
authStatus: this.userAuthStatus,
});
}
/**
* Handles the overlay button being clicked. If the user is not authenticated,
* the vault will be unlocked. If the user is authenticated, the overlay will
* be opened.
*
* @param port - The port of the overlay button
*/
private handleOverlayButtonClicked(port: chrome.runtime.Port) {
if (this.userAuthStatus !== AuthenticationStatus.Unlocked) {
// 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.unlockVault(port);
return;
}
// 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.openOverlay(false, true);
}
/**
* Facilitates opening the unlock popout window.
*
* @param port - The port of the overlay list
*/
private async unlockVault(port: chrome.runtime.Port) {
const { sender } = port;
this.closeOverlay(port);
const retryMessage: LockedVaultPendingNotificationsData = {
commandToRetry: { message: { command: "openAutofillOverlay" }, sender },
target: "overlay.background",
};
await BrowserApi.tabSendMessageData(
sender.tab,
"addToLockedVaultPendingNotifications",
retryMessage,
);
await this.openUnlockPopout(sender.tab, true);
}
/**
* Triggers the opening of a vault item popout window associated
* with the passed cipher ID.
* @param overlayCipherId - Cipher ID corresponding to the overlayLoginCiphers map. Does not correspond to the actual cipher's ID.
* @param sender - The sender of the port message
*/
private async viewSelectedCipher(
{ overlayCipherId }: OverlayPortMessage,
{ sender }: chrome.runtime.Port,
) {
const cipher = this.overlayLoginCiphers.get(overlayCipherId);
if (!cipher) {
return;
}
await this.openViewVaultItemPopout(sender.tab, {
cipherId: cipher.id,
action: SHOW_AUTOFILL_BUTTON,
});
}
/**
* Facilitates redirecting focus to the overlay list.
*/
private focusOverlayList() {
this.overlayListPort?.postMessage({ command: "focusOverlayList" });
}
/**
* Updates the authentication status for the user and opens the overlay if
* a followup command is present in the message.
*
* @param message - Extension message received from the `unlockCompleted` command
*/
private async unlockCompleted(message: OverlayBackgroundExtensionMessage) {
await this.getAuthStatus();
if (message.data?.commandToRetry?.message?.command === "openAutofillOverlay") {
await this.openOverlay(true);
}
}
/**
* Gets the translations for the overlay page.
*/
private getTranslations() {
if (!this.overlayPageTranslations) {
this.overlayPageTranslations = {
locale: BrowserApi.getUILanguage(),
opensInANewWindow: this.i18nService.translate("opensInANewWindow"),
buttonPageTitle: this.i18nService.translate("bitwardenOverlayButton"),
toggleBitwardenVaultOverlay: this.i18nService.translate("toggleBitwardenVaultOverlay"),
listPageTitle: this.i18nService.translate("bitwardenVault"),
unlockYourAccount: this.i18nService.translate("unlockYourAccountToViewMatchingLogins"),
unlockAccount: this.i18nService.translate("unlockAccount"),
fillCredentialsFor: this.i18nService.translate("fillCredentialsFor"),
partialUsername: this.i18nService.translate("partialUsername"),
view: this.i18nService.translate("view"),
noItemsToShow: this.i18nService.translate("noItemsToShow"),
newItem: this.i18nService.translate("newItem"),
addNewVaultItem: this.i18nService.translate("addNewVaultItem"),
};
}
return this.overlayPageTranslations;
}
/**
* Facilitates redirecting focus out of one of the
* overlay elements to elements on the page.
*
* @param direction - The direction to redirect focus to (either "next", "previous" or "current)
* @param sender - The sender of the port message
*/
private redirectOverlayFocusOut(
{ direction }: OverlayPortMessage,
{ sender }: chrome.runtime.Port,
) {
if (!direction) {
return;
}
// 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.tabSendMessageData(sender.tab, "redirectOverlayFocusOut", { direction });
}
/**
* Triggers adding a new vault item from the overlay. Gathers data
* input by the user before calling to open the add/edit window.
*
* @param sender - The sender of the port message
*/
private getNewVaultItemDetails({ sender }: chrome.runtime.Port) {
void BrowserApi.tabSendMessage(sender.tab, { command: "addNewVaultItemFromOverlay" });
}
/**
* Handles adding a new vault item from the overlay. Gathers data login
* data captured in the extension message.
*
* @param login - The login data captured from the extension message
* @param sender - The sender of the extension message
*/
private async addNewVaultItem(
{ login }: OverlayAddNewItemMessage,
sender: chrome.runtime.MessageSender,
) {
if (!login) {
return;
}
const uriView = new LoginUriView();
uriView.uri = login.uri;
const loginView = new LoginView();
loginView.uris = [uriView];
loginView.username = login.username || "";
loginView.password = login.password || "";
const cipherView = new CipherView();
cipherView.name = (Utils.getHostname(login.uri) || login.hostname).replace(/^www\./, "");
cipherView.folderId = null;
cipherView.type = CipherType.Login;
cipherView.login = loginView;
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
await this.cipherService.setAddEditCipherInfo(
{
cipher: cipherView,
collectionIds: cipherView.collectionIds,
},
activeUserId,
);
await this.openAddEditVaultItemPopout(sender.tab, { cipherId: cipherView.id });
}
/**
* Sets up the extension message listeners for the overlay.
*/
private setupExtensionMessageListeners() {
BrowserApi.messageListener("overlay.background", this.handleExtensionMessage);
BrowserApi.addListener(chrome.runtime.onConnect, this.handlePortOnConnect);
}
/**
* Handles extension messages sent to the extension background.
*
* @param message - The message received from the extension
* @param sender - The sender of the message
* @param sendResponse - The response to send back to the sender
*/
private handleExtensionMessage = (
message: OverlayBackgroundExtensionMessage,
sender: chrome.runtime.MessageSender,
sendResponse: (response?: any) => void,
) => {
const handler: CallableFunction | undefined = this.extensionMessageHandlers[message?.command];
if (!handler) {
return null;
}
const messageResponse = handler({ message, sender });
if (typeof messageResponse === "undefined") {
return null;
}
// 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
Promise.resolve(messageResponse).then((response) => sendResponse(response));
return true;
};
/**
* Handles the connection of a port to the extension background.
*
* @param port - The port that connected to the extension background
*/
private handlePortOnConnect = async (port: chrome.runtime.Port) => {
const isOverlayListPort = port.name === AutofillOverlayPort.List;
const isOverlayButtonPort = port.name === AutofillOverlayPort.Button;
if (!isOverlayListPort && !isOverlayButtonPort) {
return;
}
this.storeOverlayPort(port);
port.onMessage.addListener(this.handleOverlayElementPortMessage);
port.postMessage({
command: `initAutofillOverlay${isOverlayListPort ? "List" : "Button"}`,
authStatus: await this.getAuthStatus(),
styleSheetUrl: chrome.runtime.getURL(`overlay/${isOverlayListPort ? "list" : "button"}.css`),
theme: await firstValueFrom(this.themeStateService.selectedTheme$),
translations: this.getTranslations(),
ciphers: isOverlayListPort ? await this.getOverlayCipherData() : null,
});
this.updateOverlayPosition(
{
overlayElement: isOverlayListPort
? AutofillOverlayElement.List
: AutofillOverlayElement.Button,
},
port.sender,
);
};
/**
* Stores the connected overlay port and sets up any existing ports to be disconnected.
*
* @param port - The port to store
| */
private storeOverlayPort(port: chrome.runtime.Port) {
if (port.name === AutofillOverlayPort.List) {
this.storeExpiredOverlayPort(this.overlayListPort);
this.overlayListPort = port;
return;
}
if (port.name === AutofillOverlayPort.Button) {
this.storeExpiredOverlayPort(this.overlayButtonPort);
this.overlayButtonPort = port;
}
}
/**
* When registering a new connection, we want to ensure that the port is disconnected.
* This method places an existing port in the expiredPorts array to be disconnected
* at a later time.
*
* @param port - The port to store in the expiredPorts array
*/
private storeExpiredOverlayPort(port: chrome.runtime.Port | null) {
if (port) {
this.expiredPorts.push(port);
}
}
/**
* Handles messages sent to the overlay list or button ports.
*
* @param message - The message received from the port
* @param port - The port that sent the message
*/
private handleOverlayElementPortMessage = (
message: OverlayBackgroundExtensionMessage,
port: chrome.runtime.Port,
) => {
const command = message?.command;
let handler: CallableFunction | undefined;
if (port.name === AutofillOverlayPort.Button) {
handler = this.overlayButtonPortMessageHandlers[command];
}
if (port.name === AutofillOverlayPort.List) {
handler = this.overlayListPortMessageHandlers[command];
}
if (!handler) {
return;
}
handler({ message, port });
};
}
export default LegacyOverlayBackground;

View File

@@ -1,41 +0,0 @@
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import AutofillScript from "../../../models/autofill-script";
type AutofillExtensionMessage = {
command: string;
tab?: chrome.tabs.Tab;
sender?: string;
fillScript?: AutofillScript;
url?: string;
pageDetailsUrl?: string;
ciphers?: any;
data?: {
authStatus?: AuthenticationStatus;
isFocusingFieldElement?: boolean;
isOverlayCiphersPopulated?: boolean;
direction?: "previous" | "next";
isOpeningFullOverlay?: boolean;
forceCloseOverlay?: boolean;
autofillOverlayVisibility?: number;
};
};
type AutofillExtensionMessageParam = { message: AutofillExtensionMessage };
type AutofillExtensionMessageHandlers = {
[key: string]: CallableFunction;
collectPageDetails: ({ message }: AutofillExtensionMessageParam) => void;
collectPageDetailsImmediately: ({ message }: AutofillExtensionMessageParam) => void;
fillForm: ({ message }: AutofillExtensionMessageParam) => void;
openAutofillOverlay: ({ message }: AutofillExtensionMessageParam) => void;
closeAutofillOverlay: ({ message }: AutofillExtensionMessageParam) => void;
addNewVaultItemFromOverlay: () => void;
redirectOverlayFocusOut: ({ message }: AutofillExtensionMessageParam) => void;
updateIsOverlayCiphersPopulated: ({ message }: AutofillExtensionMessageParam) => void;
bgUnlockPopoutOpened: () => void;
bgVaultItemRepromptPopoutOpened: () => void;
updateAutofillOverlayVisibility: ({ message }: AutofillExtensionMessageParam) => void;
};
export { AutofillExtensionMessage, AutofillExtensionMessageHandlers };

View File

@@ -1,604 +0,0 @@
import { mock } from "jest-mock-extended";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants";
import { RedirectFocusDirection } from "../../enums/autofill-overlay.enum";
import AutofillPageDetails from "../../models/autofill-page-details";
import AutofillScript from "../../models/autofill-script";
import {
flushPromises,
mockQuerySelectorAllDefinedCall,
sendMockExtensionMessage,
} from "../../spec/testing-utils";
import AutofillOverlayContentServiceDeprecated from "../services/autofill-overlay-content.service.deprecated";
import { AutofillExtensionMessage } from "./abstractions/autofill-init.deprecated";
import AutofillInitDeprecated from "./autofill-init.deprecated";
describe("AutofillInit", () => {
let autofillInit: AutofillInitDeprecated;
const autofillOverlayContentService = mock<AutofillOverlayContentServiceDeprecated>();
const originalDocumentReadyState = document.readyState;
const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall();
beforeEach(() => {
chrome.runtime.connect = jest.fn().mockReturnValue({
onDisconnect: {
addListener: jest.fn(),
},
});
autofillInit = new AutofillInitDeprecated(autofillOverlayContentService);
window.IntersectionObserver = jest.fn(() => mock<IntersectionObserver>());
});
afterEach(() => {
jest.resetModules();
jest.clearAllMocks();
Object.defineProperty(document, "readyState", {
value: originalDocumentReadyState,
writable: true,
});
});
afterAll(() => {
mockQuerySelectorAll.mockRestore();
});
describe("init", () => {
it("sets up the extension message listeners", () => {
jest.spyOn(autofillInit as any, "setupExtensionMessageListeners");
autofillInit.init();
expect(autofillInit["setupExtensionMessageListeners"]).toHaveBeenCalled();
});
it("triggers a collection of page details if the document is in a `complete` ready state", () => {
jest.useFakeTimers();
Object.defineProperty(document, "readyState", { value: "complete", writable: true });
autofillInit.init();
jest.advanceTimersByTime(250);
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith(
{
command: "bgCollectPageDetails",
sender: "autofillInit",
},
expect.any(Function),
);
});
it("registers a window load listener to collect the page details if the document is not in a `complete` ready state", () => {
jest.spyOn(window, "addEventListener");
Object.defineProperty(document, "readyState", { value: "loading", writable: true });
autofillInit.init();
expect(window.addEventListener).toHaveBeenCalledWith("load", expect.any(Function));
});
});
describe("setupExtensionMessageListeners", () => {
it("sets up a chrome runtime on message listener", () => {
jest.spyOn(chrome.runtime.onMessage, "addListener");
autofillInit["setupExtensionMessageListeners"]();
expect(chrome.runtime.onMessage.addListener).toHaveBeenCalledWith(
autofillInit["handleExtensionMessage"],
);
});
});
describe("handleExtensionMessage", () => {
let message: AutofillExtensionMessage;
let sender: chrome.runtime.MessageSender;
const sendResponse = jest.fn();
beforeEach(() => {
message = {
command: "collectPageDetails",
tab: mock<chrome.tabs.Tab>(),
sender: "sender",
};
sender = mock<chrome.runtime.MessageSender>();
});
it("returns a undefined value if a extension message handler is not found with the given message command", () => {
message.command = "unknownCommand";
const response = autofillInit["handleExtensionMessage"](message, sender, sendResponse);
expect(response).toBe(null);
});
it("returns a undefined value if the message handler does not return a response", async () => {
const response1 = await autofillInit["handleExtensionMessage"](message, sender, sendResponse);
await flushPromises();
expect(response1).not.toBe(false);
message.command = "removeAutofillOverlay";
message.fillScript = mock<AutofillScript>();
const response2 = autofillInit["handleExtensionMessage"](message, sender, sendResponse);
await flushPromises();
expect(response2).toBe(null);
});
it("returns a true value and calls sendResponse if the message handler returns a response", async () => {
message.command = "collectPageDetailsImmediately";
const pageDetails: AutofillPageDetails = {
title: "title",
url: "http://example.com",
documentUrl: "documentUrl",
forms: {},
fields: [],
collectedTimestamp: 0,
};
jest
.spyOn(autofillInit["collectAutofillContentService"], "getPageDetails")
.mockResolvedValue(pageDetails);
const response = await autofillInit["handleExtensionMessage"](message, sender, sendResponse);
await flushPromises();
expect(response).toBe(true);
expect(sendResponse).toHaveBeenCalledWith(pageDetails);
});
describe("extension message handlers", () => {
beforeEach(() => {
autofillInit.init();
});
describe("collectPageDetails", () => {
it("sends the collected page details for autofill using a background script message", async () => {
const pageDetails: AutofillPageDetails = {
title: "title",
url: "http://example.com",
documentUrl: "documentUrl",
forms: {},
fields: [],
collectedTimestamp: 0,
};
const message = {
command: "collectPageDetails",
sender: "sender",
tab: mock<chrome.tabs.Tab>(),
};
jest
.spyOn(autofillInit["collectAutofillContentService"], "getPageDetails")
.mockResolvedValue(pageDetails);
sendMockExtensionMessage(message, sender, sendResponse);
await flushPromises();
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
command: "collectPageDetailsResponse",
tab: message.tab,
details: pageDetails,
sender: message.sender,
});
});
});
describe("collectPageDetailsImmediately", () => {
it("returns collected page details for autofill if set to send the details in the response", async () => {
const pageDetails: AutofillPageDetails = {
title: "title",
url: "http://example.com",
documentUrl: "documentUrl",
forms: {},
fields: [],
collectedTimestamp: 0,
};
jest
.spyOn(autofillInit["collectAutofillContentService"], "getPageDetails")
.mockResolvedValue(pageDetails);
sendMockExtensionMessage(
{ command: "collectPageDetailsImmediately" },
sender,
sendResponse,
);
await flushPromises();
expect(autofillInit["collectAutofillContentService"].getPageDetails).toHaveBeenCalled();
expect(sendResponse).toBeCalledWith(pageDetails);
expect(chrome.runtime.sendMessage).not.toHaveBeenCalledWith({
command: "collectPageDetailsResponse",
tab: message.tab,
details: pageDetails,
sender: message.sender,
});
});
});
describe("fillForm", () => {
let fillScript: AutofillScript;
beforeEach(() => {
fillScript = mock<AutofillScript>();
jest.spyOn(autofillInit["insertAutofillContentService"], "fillForm").mockImplementation();
});
it("skips calling the InsertAutofillContentService and does not fill the form if the url to fill is not equal to the current tab url", async () => {
const fillScript = mock<AutofillScript>();
const message = {
command: "fillForm",
fillScript,
pageDetailsUrl: "https://a-different-url.com",
};
sendMockExtensionMessage(message);
await flushPromises();
expect(autofillInit["insertAutofillContentService"].fillForm).not.toHaveBeenCalledWith(
fillScript,
);
});
it("calls the InsertAutofillContentService to fill the form", async () => {
sendMockExtensionMessage({
command: "fillForm",
fillScript,
pageDetailsUrl: window.location.href,
});
await flushPromises();
expect(autofillInit["insertAutofillContentService"].fillForm).toHaveBeenCalledWith(
fillScript,
);
});
it("removes the overlay when filling the form", async () => {
const blurAndRemoveOverlaySpy = jest.spyOn(autofillInit as any, "blurAndRemoveOverlay");
sendMockExtensionMessage({
command: "fillForm",
fillScript,
pageDetailsUrl: window.location.href,
});
await flushPromises();
expect(blurAndRemoveOverlaySpy).toHaveBeenCalled();
});
it("updates the isCurrentlyFilling property of the overlay to true after filling", async () => {
jest.useFakeTimers();
jest.spyOn(autofillInit as any, "updateOverlayIsCurrentlyFilling");
jest
.spyOn(autofillInit["autofillOverlayContentService"], "focusMostRecentOverlayField")
.mockImplementation();
sendMockExtensionMessage({
command: "fillForm",
fillScript,
pageDetailsUrl: window.location.href,
});
await flushPromises();
jest.advanceTimersByTime(300);
expect(autofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith(1, true);
expect(autofillInit["insertAutofillContentService"].fillForm).toHaveBeenCalledWith(
fillScript,
);
expect(autofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith(2, false);
});
it("skips attempting to focus the most recent field if the autofillOverlayContentService is not present", async () => {
jest.useFakeTimers();
const newAutofillInit = new AutofillInitDeprecated(undefined);
newAutofillInit.init();
jest.spyOn(newAutofillInit as any, "updateOverlayIsCurrentlyFilling");
jest
.spyOn(newAutofillInit["insertAutofillContentService"], "fillForm")
.mockImplementation();
sendMockExtensionMessage({
command: "fillForm",
fillScript,
pageDetailsUrl: window.location.href,
});
await flushPromises();
jest.advanceTimersByTime(300);
expect(newAutofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith(
1,
true,
);
expect(newAutofillInit["insertAutofillContentService"].fillForm).toHaveBeenCalledWith(
fillScript,
);
expect(newAutofillInit["updateOverlayIsCurrentlyFilling"]).not.toHaveBeenNthCalledWith(
2,
false,
);
});
});
describe("openAutofillOverlay", () => {
const message = {
command: "openAutofillOverlay",
data: {
isFocusingFieldElement: true,
isOpeningFullOverlay: true,
authStatus: AuthenticationStatus.Unlocked,
},
};
it("skips attempting to open the autofill overlay if the autofillOverlayContentService is not present", () => {
const newAutofillInit = new AutofillInitDeprecated(undefined);
newAutofillInit.init();
sendMockExtensionMessage(message);
expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined);
});
it("opens the autofill overlay", () => {
sendMockExtensionMessage(message);
expect(
autofillInit["autofillOverlayContentService"].openAutofillOverlay,
).toHaveBeenCalledWith({
isFocusingFieldElement: message.data.isFocusingFieldElement,
isOpeningFullOverlay: message.data.isOpeningFullOverlay,
authStatus: message.data.authStatus,
});
});
});
describe("closeAutofillOverlay", () => {
beforeEach(() => {
autofillInit["autofillOverlayContentService"].isFieldCurrentlyFocused = false;
autofillInit["autofillOverlayContentService"].isCurrentlyFilling = false;
});
it("skips attempting to remove the overlay if the autofillOverlayContentService is not present", () => {
const newAutofillInit = new AutofillInitDeprecated(undefined);
newAutofillInit.init();
jest.spyOn(newAutofillInit as any, "removeAutofillOverlay");
sendMockExtensionMessage({
command: "closeAutofillOverlay",
data: { forceCloseOverlay: false },
});
expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined);
});
it("removes the autofill overlay if the message flags a forced closure", () => {
sendMockExtensionMessage({
command: "closeAutofillOverlay",
data: { forceCloseOverlay: true },
});
expect(
autofillInit["autofillOverlayContentService"].removeAutofillOverlay,
).toHaveBeenCalled();
});
it("ignores the message if a field is currently focused", () => {
autofillInit["autofillOverlayContentService"].isFieldCurrentlyFocused = true;
sendMockExtensionMessage({ command: "closeAutofillOverlay" });
expect(
autofillInit["autofillOverlayContentService"].removeAutofillOverlayList,
).not.toHaveBeenCalled();
expect(
autofillInit["autofillOverlayContentService"].removeAutofillOverlay,
).not.toHaveBeenCalled();
});
it("removes the autofill overlay list if the overlay is currently filling", () => {
autofillInit["autofillOverlayContentService"].isCurrentlyFilling = true;
sendMockExtensionMessage({ command: "closeAutofillOverlay" });
expect(
autofillInit["autofillOverlayContentService"].removeAutofillOverlayList,
).toHaveBeenCalled();
expect(
autofillInit["autofillOverlayContentService"].removeAutofillOverlay,
).not.toHaveBeenCalled();
});
it("removes the entire overlay if the overlay is not currently filling", () => {
sendMockExtensionMessage({ command: "closeAutofillOverlay" });
expect(
autofillInit["autofillOverlayContentService"].removeAutofillOverlayList,
).not.toHaveBeenCalled();
expect(
autofillInit["autofillOverlayContentService"].removeAutofillOverlay,
).toHaveBeenCalled();
});
});
describe("addNewVaultItemFromOverlay", () => {
it("will not add a new vault item if the autofillOverlayContentService is not present", () => {
const newAutofillInit = new AutofillInitDeprecated(undefined);
newAutofillInit.init();
sendMockExtensionMessage({ command: "addNewVaultItemFromOverlay" });
expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined);
});
it("will add a new vault item", () => {
sendMockExtensionMessage({ command: "addNewVaultItemFromOverlay" });
expect(autofillInit["autofillOverlayContentService"].addNewVaultItem).toHaveBeenCalled();
});
});
describe("redirectOverlayFocusOut", () => {
const message = {
command: "redirectOverlayFocusOut",
data: {
direction: RedirectFocusDirection.Next,
},
};
it("ignores the message to redirect focus if the autofillOverlayContentService does not exist", () => {
const newAutofillInit = new AutofillInitDeprecated(undefined);
newAutofillInit.init();
sendMockExtensionMessage(message);
expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined);
});
it("redirects the overlay focus", () => {
sendMockExtensionMessage(message);
expect(
autofillInit["autofillOverlayContentService"].redirectOverlayFocusOut,
).toHaveBeenCalledWith(message.data.direction);
});
});
describe("updateIsOverlayCiphersPopulated", () => {
const message = {
command: "updateIsOverlayCiphersPopulated",
data: {
isOverlayCiphersPopulated: true,
},
};
it("skips updating whether the ciphers are populated if the autofillOverlayContentService does note exist", () => {
const newAutofillInit = new AutofillInitDeprecated(undefined);
newAutofillInit.init();
sendMockExtensionMessage(message);
expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined);
});
it("updates whether the overlay ciphers are populated", () => {
sendMockExtensionMessage(message);
expect(autofillInit["autofillOverlayContentService"].isOverlayCiphersPopulated).toEqual(
message.data.isOverlayCiphersPopulated,
);
});
});
describe("bgUnlockPopoutOpened", () => {
it("skips attempting to blur and remove the overlay if the autofillOverlayContentService is not present", () => {
const newAutofillInit = new AutofillInitDeprecated(undefined);
newAutofillInit.init();
jest.spyOn(newAutofillInit as any, "removeAutofillOverlay");
sendMockExtensionMessage({ command: "bgUnlockPopoutOpened" });
expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined);
expect(newAutofillInit["removeAutofillOverlay"]).not.toHaveBeenCalled();
});
it("blurs the most recently focused feel and remove the autofill overlay", () => {
jest.spyOn(autofillInit["autofillOverlayContentService"], "blurMostRecentOverlayField");
jest.spyOn(autofillInit as any, "removeAutofillOverlay");
sendMockExtensionMessage({ command: "bgUnlockPopoutOpened" });
expect(
autofillInit["autofillOverlayContentService"].blurMostRecentOverlayField,
).toHaveBeenCalled();
expect(autofillInit["removeAutofillOverlay"]).toHaveBeenCalled();
});
});
describe("bgVaultItemRepromptPopoutOpened", () => {
it("skips attempting to blur and remove the overlay if the autofillOverlayContentService is not present", () => {
const newAutofillInit = new AutofillInitDeprecated(undefined);
newAutofillInit.init();
jest.spyOn(newAutofillInit as any, "removeAutofillOverlay");
sendMockExtensionMessage({ command: "bgVaultItemRepromptPopoutOpened" });
expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined);
expect(newAutofillInit["removeAutofillOverlay"]).not.toHaveBeenCalled();
});
it("blurs the most recently focused feel and remove the autofill overlay", () => {
jest.spyOn(autofillInit["autofillOverlayContentService"], "blurMostRecentOverlayField");
jest.spyOn(autofillInit as any, "removeAutofillOverlay");
sendMockExtensionMessage({ command: "bgVaultItemRepromptPopoutOpened" });
expect(
autofillInit["autofillOverlayContentService"].blurMostRecentOverlayField,
).toHaveBeenCalled();
expect(autofillInit["removeAutofillOverlay"]).toHaveBeenCalled();
});
});
describe("updateAutofillOverlayVisibility", () => {
beforeEach(() => {
autofillInit["autofillOverlayContentService"].autofillOverlayVisibility =
AutofillOverlayVisibility.OnButtonClick;
});
it("skips attempting to update the overlay visibility if the autofillOverlayVisibility data value is not present", () => {
sendMockExtensionMessage({
command: "updateAutofillOverlayVisibility",
data: {},
});
expect(autofillInit["autofillOverlayContentService"].autofillOverlayVisibility).toEqual(
AutofillOverlayVisibility.OnButtonClick,
);
});
it("updates the overlay visibility value", () => {
const message = {
command: "updateAutofillOverlayVisibility",
data: {
autofillOverlayVisibility: AutofillOverlayVisibility.Off,
},
};
sendMockExtensionMessage(message);
expect(autofillInit["autofillOverlayContentService"].autofillOverlayVisibility).toEqual(
message.data.autofillOverlayVisibility,
);
});
});
});
});
describe("destroy", () => {
it("clears the timeout used to collect page details on load", () => {
jest.spyOn(window, "clearTimeout");
autofillInit.init();
autofillInit.destroy();
expect(window.clearTimeout).toHaveBeenCalledWith(
autofillInit["collectPageDetailsOnLoadTimeout"],
);
});
it("removes the extension message listeners", () => {
autofillInit.destroy();
expect(chrome.runtime.onMessage.removeListener).toHaveBeenCalledWith(
autofillInit["handleExtensionMessage"],
);
});
it("destroys the collectAutofillContentService", () => {
jest.spyOn(autofillInit["collectAutofillContentService"], "destroy");
autofillInit.destroy();
expect(autofillInit["collectAutofillContentService"].destroy).toHaveBeenCalled();
});
});
});

View File

@@ -1,315 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { AutofillInit } from "../../content/abstractions/autofill-init";
import AutofillPageDetails from "../../models/autofill-page-details";
import { CollectAutofillContentService } from "../../services/collect-autofill-content.service";
import DomElementVisibilityService from "../../services/dom-element-visibility.service";
import { DomQueryService } from "../../services/dom-query.service";
import InsertAutofillContentService from "../../services/insert-autofill-content.service";
import { sendExtensionMessage } from "../../utils";
import { LegacyAutofillOverlayContentService } from "../services/abstractions/autofill-overlay-content.service";
import {
AutofillExtensionMessage,
AutofillExtensionMessageHandlers,
} from "./abstractions/autofill-init.deprecated";
class LegacyAutofillInit implements AutofillInit {
private readonly autofillOverlayContentService: LegacyAutofillOverlayContentService | undefined;
private readonly domElementVisibilityService: DomElementVisibilityService;
private readonly collectAutofillContentService: CollectAutofillContentService;
private readonly insertAutofillContentService: InsertAutofillContentService;
private collectPageDetailsOnLoadTimeout: number | NodeJS.Timeout | undefined;
private readonly extensionMessageHandlers: AutofillExtensionMessageHandlers = {
collectPageDetails: ({ message }) => this.collectPageDetails(message),
collectPageDetailsImmediately: ({ message }) => this.collectPageDetails(message, true),
fillForm: ({ message }) => this.fillForm(message),
openAutofillOverlay: ({ message }) => this.openAutofillOverlay(message),
closeAutofillOverlay: ({ message }) => this.removeAutofillOverlay(message),
addNewVaultItemFromOverlay: () => this.addNewVaultItemFromOverlay(),
redirectOverlayFocusOut: ({ message }) => this.redirectOverlayFocusOut(message),
updateIsOverlayCiphersPopulated: ({ message }) => this.updateIsOverlayCiphersPopulated(message),
bgUnlockPopoutOpened: () => this.blurAndRemoveOverlay(),
bgVaultItemRepromptPopoutOpened: () => this.blurAndRemoveOverlay(),
updateAutofillOverlayVisibility: ({ message }) => this.updateAutofillOverlayVisibility(message),
};
/**
* AutofillInit constructor. Initializes the DomElementVisibilityService,
* CollectAutofillContentService and InsertAutofillContentService classes.
*
* @param autofillOverlayContentService - The autofill overlay content service, potentially undefined.
*/
constructor(autofillOverlayContentService?: LegacyAutofillOverlayContentService) {
this.autofillOverlayContentService = autofillOverlayContentService;
this.domElementVisibilityService = new DomElementVisibilityService();
const domQueryService = new DomQueryService();
this.collectAutofillContentService = new CollectAutofillContentService(
this.domElementVisibilityService,
domQueryService,
this.autofillOverlayContentService,
);
this.insertAutofillContentService = new InsertAutofillContentService(
this.domElementVisibilityService,
this.collectAutofillContentService,
);
}
/**
* Initializes the autofill content script, setting up
* the extension message listeners. This method should
* be called once when the content script is loaded.
*/
init() {
this.setupExtensionMessageListeners();
this.autofillOverlayContentService?.init();
this.collectPageDetailsOnLoad();
}
/**
* Triggers a collection of the page details from the
* background script, ensuring that autofill is ready
* to act on the page.
*/
private collectPageDetailsOnLoad() {
const sendCollectDetailsMessage = () => {
this.clearCollectPageDetailsOnLoadTimeout();
this.collectPageDetailsOnLoadTimeout = setTimeout(
() => sendExtensionMessage("bgCollectPageDetails", { sender: "autofillInit" }),
250,
);
};
if (globalThis.document.readyState === "complete") {
sendCollectDetailsMessage();
}
globalThis.addEventListener("load", sendCollectDetailsMessage);
}
/**
* Collects the page details and sends them to the
* extension background script. If the `sendDetailsInResponse`
* parameter is set to true, the page details will be
* returned to facilitate sending the details in the
* response to the extension message.
*
* @param message - The extension message.
* @param sendDetailsInResponse - Determines whether to send the details in the response.
*/
private async collectPageDetails(
message: AutofillExtensionMessage,
sendDetailsInResponse = false,
): Promise<AutofillPageDetails | void> {
const pageDetails: AutofillPageDetails =
await this.collectAutofillContentService.getPageDetails();
if (sendDetailsInResponse) {
return pageDetails;
}
void chrome.runtime.sendMessage({
command: "collectPageDetailsResponse",
tab: message.tab,
details: pageDetails,
sender: message.sender,
});
}
/**
* Fills the form with the given fill script.
*
* @param {AutofillExtensionMessage} message
*/
private async fillForm({ fillScript, pageDetailsUrl }: AutofillExtensionMessage) {
if ((document.defaultView || window).location.href !== pageDetailsUrl) {
return;
}
this.blurAndRemoveOverlay();
this.updateOverlayIsCurrentlyFilling(true);
await this.insertAutofillContentService.fillForm(fillScript);
if (!this.autofillOverlayContentService) {
return;
}
setTimeout(() => this.updateOverlayIsCurrentlyFilling(false), 250);
}
/**
* Handles updating the overlay is currently filling value.
*
* @param isCurrentlyFilling - Indicates if the overlay is currently filling
*/
private updateOverlayIsCurrentlyFilling(isCurrentlyFilling: boolean) {
if (!this.autofillOverlayContentService) {
return;
}
this.autofillOverlayContentService.isCurrentlyFilling = isCurrentlyFilling;
}
/**
* Opens the autofill overlay.
*
* @param data - The extension message data.
*/
private openAutofillOverlay({ data }: AutofillExtensionMessage) {
if (!this.autofillOverlayContentService) {
return;
}
this.autofillOverlayContentService.openAutofillOverlay(data);
}
/**
* Blurs the most recent overlay field and removes the overlay. Used
* in cases where the background unlock or vault item reprompt popout
* is opened.
*/
private blurAndRemoveOverlay() {
if (!this.autofillOverlayContentService) {
return;
}
this.autofillOverlayContentService.blurMostRecentOverlayField();
this.removeAutofillOverlay();
}
/**
* Removes the autofill overlay if the field is not currently focused.
* If the autofill is currently filling, only the overlay list will be
* removed.
*/
private removeAutofillOverlay(message?: AutofillExtensionMessage) {
if (message?.data?.forceCloseOverlay) {
this.autofillOverlayContentService?.removeAutofillOverlay();
return;
}
if (
!this.autofillOverlayContentService ||
this.autofillOverlayContentService.isFieldCurrentlyFocused
) {
return;
}
if (this.autofillOverlayContentService.isCurrentlyFilling) {
this.autofillOverlayContentService.removeAutofillOverlayList();
return;
}
this.autofillOverlayContentService.removeAutofillOverlay();
}
/**
* Adds a new vault item from the overlay.
*/
private addNewVaultItemFromOverlay() {
if (!this.autofillOverlayContentService) {
return;
}
this.autofillOverlayContentService.addNewVaultItem();
}
/**
* Redirects the overlay focus out of an overlay iframe.
*
* @param data - Contains the direction to redirect the focus.
*/
private redirectOverlayFocusOut({ data }: AutofillExtensionMessage) {
if (!this.autofillOverlayContentService) {
return;
}
this.autofillOverlayContentService.redirectOverlayFocusOut(data?.direction);
}
/**
* Updates whether the current tab has ciphers that can populate the overlay list
*
* @param data - Contains the isOverlayCiphersPopulated value
*
*/
private updateIsOverlayCiphersPopulated({ data }: AutofillExtensionMessage) {
if (!this.autofillOverlayContentService) {
return;
}
this.autofillOverlayContentService.isOverlayCiphersPopulated = Boolean(
data?.isOverlayCiphersPopulated,
);
}
/**
* Updates the autofill overlay visibility.
*
* @param data - Contains the autoFillOverlayVisibility value
*/
private updateAutofillOverlayVisibility({ data }: AutofillExtensionMessage) {
if (!this.autofillOverlayContentService || isNaN(data?.autofillOverlayVisibility)) {
return;
}
this.autofillOverlayContentService.autofillOverlayVisibility = data?.autofillOverlayVisibility;
}
/**
* Clears the send collect details message timeout.
*/
private clearCollectPageDetailsOnLoadTimeout() {
if (this.collectPageDetailsOnLoadTimeout) {
clearTimeout(this.collectPageDetailsOnLoadTimeout);
}
}
/**
* Sets up the extension message listeners for the content script.
*/
private setupExtensionMessageListeners() {
chrome.runtime.onMessage.addListener(this.handleExtensionMessage);
}
/**
* Handles the extension messages sent to the content script.
*
* @param message - The extension message.
* @param sender - The message sender.
* @param sendResponse - The send response callback.
*/
private handleExtensionMessage = (
message: AutofillExtensionMessage,
sender: chrome.runtime.MessageSender,
sendResponse: (response?: any) => void,
): boolean => {
const command: string = message.command;
const handler: CallableFunction | undefined = this.extensionMessageHandlers[command];
if (!handler) {
return null;
}
const messageResponse = handler({ message, sender });
if (typeof messageResponse === "undefined") {
return null;
}
// 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
Promise.resolve(messageResponse).then((response) => sendResponse(response));
return true;
};
/**
* Handles destroying the autofill init content script. Removes all
* listeners, timeouts, and object instances to prevent memory leaks.
*/
destroy() {
this.clearCollectPageDetailsOnLoadTimeout();
chrome.runtime.onMessage.removeListener(this.handleExtensionMessage);
this.collectAutofillContentService.destroy();
this.autofillOverlayContentService?.destroy();
}
}
export default LegacyAutofillInit;

View File

@@ -1,14 +0,0 @@
import { setupAutofillInitDisconnectAction } from "../../utils";
import LegacyAutofillOverlayContentService from "../services/autofill-overlay-content.service.deprecated";
import LegacyAutofillInit from "./autofill-init.deprecated";
(function (windowContext) {
if (!windowContext.bitwardenAutofillInit) {
const autofillOverlayContentService = new LegacyAutofillOverlayContentService();
windowContext.bitwardenAutofillInit = new LegacyAutofillInit(autofillOverlayContentService);
setupAutofillInitDisconnectAction(windowContext);
windowContext.bitwardenAutofillInit.init();
}
})(window);

View File

@@ -1,29 +0,0 @@
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
type OverlayButtonMessage = { command: string; colorScheme?: string };
type UpdateAuthStatusMessage = OverlayButtonMessage & { authStatus: AuthenticationStatus };
type InitAutofillOverlayButtonMessage = UpdateAuthStatusMessage & {
styleSheetUrl: string;
translations: Record<string, string>;
};
type OverlayButtonWindowMessageHandlers = {
[key: string]: CallableFunction;
initAutofillOverlayButton: ({ message }: { message: InitAutofillOverlayButtonMessage }) => void;
checkAutofillOverlayButtonFocused: () => void;
updateAutofillOverlayButtonAuthStatus: ({
message,
}: {
message: UpdateAuthStatusMessage;
}) => void;
updateOverlayPageColorScheme: ({ message }: { message: OverlayButtonMessage }) => void;
};
export {
UpdateAuthStatusMessage,
OverlayButtonMessage,
InitAutofillOverlayButtonMessage,
OverlayButtonWindowMessageHandlers,
};

View File

@@ -1,33 +0,0 @@
type AutofillOverlayIframeExtensionMessage = {
command: string;
styles?: Partial<CSSStyleDeclaration>;
theme?: string;
};
type AutofillOverlayIframeWindowMessageHandlers = {
[key: string]: CallableFunction;
updateAutofillOverlayListHeight: (message: AutofillOverlayIframeExtensionMessage) => void;
getPageColorScheme: () => void;
};
type AutofillOverlayIframeExtensionMessageParam = {
message: AutofillOverlayIframeExtensionMessage;
};
type BackgroundPortMessageHandlers = {
[key: string]: CallableFunction;
initAutofillOverlayList: ({ message }: AutofillOverlayIframeExtensionMessageParam) => void;
updateIframePosition: ({ message }: AutofillOverlayIframeExtensionMessageParam) => void;
updateOverlayHidden: ({ message }: AutofillOverlayIframeExtensionMessageParam) => void;
};
interface AutofillOverlayIframeService {
initOverlayIframe(initStyles: Partial<CSSStyleDeclaration>, ariaAlert?: string): void;
}
export {
AutofillOverlayIframeExtensionMessage,
AutofillOverlayIframeWindowMessageHandlers,
BackgroundPortMessageHandlers,
AutofillOverlayIframeService,
};

View File

@@ -1,31 +0,0 @@
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { OverlayCipherData } from "../../background/abstractions/overlay.background.deprecated";
type OverlayListMessage = { command: string };
type UpdateOverlayListCiphersMessage = OverlayListMessage & {
ciphers: OverlayCipherData[];
};
type InitAutofillOverlayListMessage = OverlayListMessage & {
authStatus: AuthenticationStatus;
styleSheetUrl: string;
theme: string;
translations: Record<string, string>;
ciphers?: OverlayCipherData[];
};
type OverlayListWindowMessageHandlers = {
[key: string]: CallableFunction;
initAutofillOverlayList: ({ message }: { message: InitAutofillOverlayListMessage }) => void;
checkAutofillOverlayListFocused: () => void;
updateOverlayListCiphers: ({ message }: { message: UpdateOverlayListCiphersMessage }) => void;
focusOverlayList: () => void;
};
export {
UpdateOverlayListCiphersMessage,
InitAutofillOverlayListMessage,
OverlayListWindowMessageHandlers,
};

View File

@@ -1,13 +0,0 @@
import { OverlayButtonWindowMessageHandlers } from "./autofill-overlay-button.deprecated";
import { OverlayListWindowMessageHandlers } from "./autofill-overlay-list.deprecated";
type WindowMessageHandlers = OverlayButtonWindowMessageHandlers | OverlayListWindowMessageHandlers;
type AutofillOverlayPageElementWindowMessage = {
[key: string]: any;
command: string;
overlayCipherId?: string;
height?: number;
};
export { WindowMessageHandlers, AutofillOverlayPageElementWindowMessage };

View File

@@ -1,23 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AutofillOverlayIframeService initOverlayIframe creates an aria alert element if the ariaAlert param is passed 1`] = `
<div
aria-atomic="true"
aria-live="polite"
role="status"
style="position: absolute !important; top: -9999px !important; left: -9999px !important; width: 1px !important; height: 1px !important; overflow: hidden !important; opacity: 0 !important; pointer-events: none;"
>
aria alert
</div>
`;
exports[`AutofillOverlayIframeService initOverlayIframe sets up the iframe's attributes 1`] = `
<iframe
allowtransparency="true"
sandbox="allow-scripts"
src="chrome-extension://id/overlay/legacy-list.html"
style="all: initial !important; position: fixed !important; display: block !important; z-index: 2147483647 !important; line-height: 0 !important; overflow: hidden !important; transition: opacity 125ms ease-out 0s !important; visibility: visible !important; clip-path: none !important; pointer-events: auto !important; margin: 0px !important; padding: 0px !important; color-scheme: normal !important; opacity: 0 !important; height: 0px;"
tabindex="-1"
title="title"
/>
`;

View File

@@ -1,26 +0,0 @@
import AutofillOverlayButtonIframe from "./autofill-overlay-button-iframe.deprecated";
describe("AutofillOverlayButtonIframe", () => {
window.customElements.define(
"autofill-overlay-button-iframe",
class extends HTMLElement {
constructor() {
super();
new AutofillOverlayButtonIframe(this);
}
},
);
afterAll(() => {
jest.clearAllMocks();
});
it("creates a custom element that is an instance of the AutofillIframeElement parent class", () => {
document.body.innerHTML = "<autofill-overlay-button-iframe></autofill-overlay-button-iframe>";
const iframe = document.querySelector("autofill-overlay-button-iframe");
expect(iframe).toBeInstanceOf(HTMLElement);
expect(iframe.shadowRoot).toBeDefined();
});
});

View File

@@ -1,21 +0,0 @@
import { AutofillOverlayPort } from "../../../enums/autofill-overlay.enum";
import AutofillOverlayIframeElement from "./autofill-overlay-iframe-element.deprecated";
class AutofillOverlayButtonIframe extends AutofillOverlayIframeElement {
constructor(element: HTMLElement) {
super(
element,
"overlay/button.html",
AutofillOverlayPort.Button,
{
background: "transparent",
border: "none",
},
chrome.i18n.getMessage("bitwardenOverlayButton"),
chrome.i18n.getMessage("bitwardenOverlayMenuAvailable"),
);
}
}
export default AutofillOverlayButtonIframe;

View File

@@ -1,46 +0,0 @@
import AutofillOverlayIframeElement from "./autofill-overlay-iframe-element.deprecated";
import AutofillOverlayIframeService from "./autofill-overlay-iframe.service.deprecated";
jest.mock("./autofill-overlay-iframe.service.deprecated");
describe("AutofillOverlayIframeElement", () => {
window.customElements.define(
"autofill-overlay-iframe",
class extends HTMLElement {
constructor() {
super();
new AutofillOverlayIframeElement(
this,
"overlay/button.html",
"overlay/button",
{ background: "transparent", border: "none" },
"bitwardenOverlayButton",
);
}
},
);
afterAll(() => {
jest.clearAllMocks();
});
it("creates a custom element that is an instance of the HTMLElement parent class", () => {
document.body.innerHTML = "<autofill-overlay-iframe></autofill-overlay-iframe>";
const iframe = document.querySelector("autofill-overlay-iframe");
expect(iframe).toBeInstanceOf(HTMLElement);
});
it("attaches a closed shadow DOM", () => {
document.body.innerHTML = "<autofill-overlay-iframe></autofill-overlay-iframe>";
const iframe = document.querySelector("autofill-overlay-iframe");
expect(iframe.shadowRoot).toBeNull();
});
it("instantiates the autofill overlay iframe service for each attached custom element", () => {
expect(AutofillOverlayIframeService).toHaveBeenCalledTimes(2);
});
});

View File

@@ -1,22 +0,0 @@
import AutofillOverlayIframeService from "./autofill-overlay-iframe.service.deprecated";
class AutofillOverlayIframeElement {
constructor(
element: HTMLElement,
iframePath: string,
portName: string,
initStyles: Partial<CSSStyleDeclaration>,
iframeTitle: string,
ariaAlert?: string,
) {
const shadow: ShadowRoot = element.attachShadow({ mode: "closed" });
const autofillOverlayIframeService = new AutofillOverlayIframeService(
iframePath,
portName,
shadow,
);
autofillOverlayIframeService.initOverlayIframe(initStyles, iframeTitle, ariaAlert);
}
}
export default AutofillOverlayIframeElement;

View File

@@ -1,521 +0,0 @@
import { mock } from "jest-mock-extended";
import { EVENTS } from "@bitwarden/common/autofill/constants";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { AutofillOverlayPort } from "../../../enums/autofill-overlay.enum";
import { createPortSpyMock } from "../../../spec/autofill-mocks";
import {
flushPromises,
sendPortMessage,
triggerPortOnDisconnectEvent,
} from "../../../spec/testing-utils";
import AutofillOverlayIframeService from "./autofill-overlay-iframe.service.deprecated";
describe("AutofillOverlayIframeService", () => {
const iframePath = "overlay/legacy-list.html";
let autofillOverlayIframeService: AutofillOverlayIframeService;
let portSpy: chrome.runtime.Port;
let shadowAppendSpy: jest.SpyInstance;
let handlePortDisconnectSpy: jest.SpyInstance;
let handlePortMessageSpy: jest.SpyInstance;
let handleWindowMessageSpy: jest.SpyInstance;
beforeEach(() => {
const shadow = document.createElement("div").attachShadow({ mode: "open" });
autofillOverlayIframeService = new AutofillOverlayIframeService(
iframePath,
AutofillOverlayPort.Button,
shadow,
);
shadowAppendSpy = jest.spyOn(shadow, "appendChild");
handlePortDisconnectSpy = jest.spyOn(
autofillOverlayIframeService as any,
"handlePortDisconnect",
);
handlePortMessageSpy = jest.spyOn(autofillOverlayIframeService as any, "handlePortMessage");
handleWindowMessageSpy = jest.spyOn(autofillOverlayIframeService as any, "handleWindowMessage");
chrome.runtime.connect = jest.fn((connectInfo: chrome.runtime.ConnectInfo) =>
createPortSpyMock(connectInfo.name),
) as unknown as typeof chrome.runtime.connect;
});
afterEach(() => {
jest.clearAllMocks();
});
describe("initOverlayIframe", () => {
it("sets up the iframe's attributes", () => {
autofillOverlayIframeService.initOverlayIframe({ height: "0px" }, "title");
expect(autofillOverlayIframeService["iframe"]).toMatchSnapshot();
});
it("appends the iframe to the shadowDom", () => {
jest.spyOn(autofillOverlayIframeService["shadow"], "appendChild");
autofillOverlayIframeService.initOverlayIframe({}, "title");
expect(autofillOverlayIframeService["shadow"].appendChild).toBeCalledWith(
autofillOverlayIframeService["iframe"],
);
});
it("creates an aria alert element if the ariaAlert param is passed", () => {
const ariaAlert = "aria alert";
jest.spyOn(autofillOverlayIframeService as any, "createAriaAlertElement");
autofillOverlayIframeService.initOverlayIframe({}, "title", ariaAlert);
expect(autofillOverlayIframeService["createAriaAlertElement"]).toBeCalledWith(ariaAlert);
expect(autofillOverlayIframeService["ariaAlertElement"]).toMatchSnapshot();
});
describe("on load of the iframe source", () => {
beforeEach(() => {
autofillOverlayIframeService.initOverlayIframe({ height: "0px" }, "title", "ariaAlert");
});
it("sets up and connects the port message listener to the extension background", () => {
jest.spyOn(globalThis, "addEventListener");
autofillOverlayIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD));
portSpy = autofillOverlayIframeService["port"];
expect(chrome.runtime.connect).toBeCalledWith({ name: AutofillOverlayPort.Button });
expect(portSpy.onDisconnect.addListener).toBeCalledWith(handlePortDisconnectSpy);
expect(portSpy.onMessage.addListener).toBeCalledWith(handlePortMessageSpy);
expect(globalThis.addEventListener).toBeCalledWith(EVENTS.MESSAGE, handleWindowMessageSpy);
});
it("skips announcing the aria alert if the aria alert element is not populated", () => {
jest.spyOn(globalThis, "setTimeout");
autofillOverlayIframeService["ariaAlertElement"] = undefined;
autofillOverlayIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD));
expect(globalThis.setTimeout).not.toBeCalled();
});
it("announces the aria alert if the aria alert element is populated", () => {
jest.useFakeTimers();
jest.spyOn(globalThis, "setTimeout");
autofillOverlayIframeService["ariaAlertElement"] = document.createElement("div");
autofillOverlayIframeService["ariaAlertTimeout"] = setTimeout(jest.fn(), 2000);
autofillOverlayIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD));
expect(globalThis.setTimeout).toBeCalled();
jest.advanceTimersByTime(2000);
expect(shadowAppendSpy).toBeCalledWith(autofillOverlayIframeService["ariaAlertElement"]);
});
});
});
describe("event listeners", () => {
beforeEach(() => {
autofillOverlayIframeService.initOverlayIframe({ height: "0px" }, "title", "ariaAlert");
autofillOverlayIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD));
Object.defineProperty(autofillOverlayIframeService["iframe"], "contentWindow", {
value: {
postMessage: jest.fn(),
},
writable: true,
});
jest.spyOn(autofillOverlayIframeService["iframe"].contentWindow, "postMessage");
portSpy = autofillOverlayIframeService["port"];
});
describe("handlePortDisconnect", () => {
it("ignores ports that do not have the correct port name", () => {
portSpy.name = "wrong-port-name";
triggerPortOnDisconnectEvent(portSpy);
expect(autofillOverlayIframeService["port"]).not.toBeNull();
});
it("resets the iframe element's opacity, height, and display styles", () => {
triggerPortOnDisconnectEvent(portSpy);
expect(autofillOverlayIframeService["iframe"].style.opacity).toBe("0");
expect(autofillOverlayIframeService["iframe"].style.height).toBe("0px");
expect(autofillOverlayIframeService["iframe"].style.display).toBe("block");
});
it("removes the global message listener", () => {
jest.spyOn(globalThis, "removeEventListener");
triggerPortOnDisconnectEvent(portSpy);
expect(globalThis.removeEventListener).toBeCalledWith(
EVENTS.MESSAGE,
handleWindowMessageSpy,
);
});
it("removes the port's onMessage listener", () => {
triggerPortOnDisconnectEvent(portSpy);
expect(portSpy.onMessage.removeListener).toBeCalledWith(handlePortMessageSpy);
});
it("removes the port's onDisconnect listener", () => {
triggerPortOnDisconnectEvent(portSpy);
expect(portSpy.onDisconnect.removeListener).toBeCalledWith(handlePortDisconnectSpy);
});
it("disconnects the port", () => {
triggerPortOnDisconnectEvent(portSpy);
expect(portSpy.disconnect).toBeCalled();
expect(autofillOverlayIframeService["port"]).toBeNull();
});
});
describe("handlePortMessage", () => {
it("ignores port messages that do not correlate to the correct port name", () => {
portSpy.name = "wrong-port-name";
sendPortMessage(portSpy, {});
expect(autofillOverlayIframeService["iframe"].contentWindow.postMessage).not.toBeCalled();
});
it("passes on the message to the iframe if the message is not registered with the message handlers", () => {
const message = { command: "unregisteredMessage" };
sendPortMessage(portSpy, message);
expect(autofillOverlayIframeService["iframe"].contentWindow.postMessage).toBeCalledWith(
message,
"*",
);
});
it("handles port messages that are registered with the message handlers and does not pass the message on to the iframe", () => {
jest.spyOn(autofillOverlayIframeService as any, "updateIframePosition");
sendPortMessage(portSpy, { command: "updateIframePosition" });
expect(autofillOverlayIframeService["iframe"].contentWindow.postMessage).not.toBeCalled();
});
describe("initializing the overlay list", () => {
let updateElementStylesSpy: jest.SpyInstance;
beforeEach(() => {
updateElementStylesSpy = jest.spyOn(
autofillOverlayIframeService as any,
"updateElementStyles",
);
});
it("passes the message on to the iframe element", () => {
const message = {
command: "initAutofillOverlayList",
theme: ThemeType.Light,
};
sendPortMessage(portSpy, message);
expect(updateElementStylesSpy).not.toBeCalled();
expect(autofillOverlayIframeService["iframe"].contentWindow.postMessage).toBeCalledWith(
message,
"*",
);
});
it("sets a light theme based on the user's system preferences", () => {
window.matchMedia = jest.fn(() => mock<MediaQueryList>({ matches: false }));
const message = {
command: "initAutofillOverlayList",
theme: ThemeType.System,
};
sendPortMessage(portSpy, message);
expect(window.matchMedia).toHaveBeenCalledWith("(prefers-color-scheme: dark)");
expect(autofillOverlayIframeService["iframe"].contentWindow.postMessage).toBeCalledWith(
{
command: "initAutofillOverlayList",
theme: ThemeType.Light,
},
"*",
);
});
it("sets a dark theme based on the user's system preferences", () => {
window.matchMedia = jest.fn(() => mock<MediaQueryList>({ matches: true }));
const message = {
command: "initAutofillOverlayList",
theme: ThemeType.System,
};
sendPortMessage(portSpy, message);
expect(window.matchMedia).toHaveBeenCalledWith("(prefers-color-scheme: dark)");
expect(autofillOverlayIframeService["iframe"].contentWindow.postMessage).toBeCalledWith(
{
command: "initAutofillOverlayList",
theme: ThemeType.Dark,
},
"*",
);
});
it("updates the border to match the `dark` theme", () => {
const message = {
command: "initAutofillOverlayList",
theme: ThemeType.Dark,
};
sendPortMessage(portSpy, message);
expect(updateElementStylesSpy).toBeCalledWith(autofillOverlayIframeService["iframe"], {
borderColor: "#4c525f",
});
});
});
describe("updating the iframe's position", () => {
beforeEach(() => {
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
});
it("ignores updating the iframe position if the document does not have focus", () => {
jest.spyOn(autofillOverlayIframeService as any, "updateElementStyles");
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(false);
sendPortMessage(portSpy, {
command: "updateIframePosition",
styles: { top: 100, left: 100 },
});
expect(autofillOverlayIframeService["updateElementStyles"]).not.toBeCalled();
});
it("updates the iframe position if the document has focus", () => {
const styles = { top: "100px", left: "100px" };
sendPortMessage(portSpy, {
command: "updateIframePosition",
styles,
});
expect(autofillOverlayIframeService["iframe"].style.top).toBe(styles.top);
expect(autofillOverlayIframeService["iframe"].style.left).toBe(styles.left);
});
it("fades the iframe element in after positioning the element", () => {
jest.useFakeTimers();
const styles = { top: "100px", left: "100px" };
sendPortMessage(portSpy, {
command: "updateIframePosition",
styles,
});
expect(autofillOverlayIframeService["iframe"].style.opacity).toBe("0");
jest.advanceTimersByTime(10);
expect(autofillOverlayIframeService["iframe"].style.opacity).toBe("1");
});
it("announces the opening of the iframe using an aria alert", () => {
jest.useFakeTimers();
const styles = { top: "100px", left: "100px" };
sendPortMessage(portSpy, {
command: "updateIframePosition",
styles,
});
jest.advanceTimersByTime(2000);
expect(shadowAppendSpy).toBeCalledWith(autofillOverlayIframeService["ariaAlertElement"]);
});
});
it("updates the visibility of the iframe", () => {
sendPortMessage(portSpy, {
command: "updateOverlayHidden",
styles: { display: "none" },
});
expect(autofillOverlayIframeService["iframe"].style.display).toBe("none");
});
});
describe("handleWindowMessage", () => {
it("ignores window messages when the port is not set", () => {
autofillOverlayIframeService["port"] = null;
globalThis.dispatchEvent(new MessageEvent("message", { data: {} }));
expect(autofillOverlayIframeService["port"]).toBeNull();
});
it("ignores window messages whose source is not the iframe's content window", () => {
globalThis.dispatchEvent(
new MessageEvent("message", {
data: {},
source: window,
}),
);
expect(portSpy.postMessage).not.toBeCalled();
});
it("ignores window messages whose origin is not from the extension origin", () => {
globalThis.dispatchEvent(
new MessageEvent("message", {
data: {},
source: autofillOverlayIframeService["iframe"].contentWindow,
origin: "https://www.google.com",
}),
);
expect(portSpy.postMessage).not.toBeCalled();
});
it("passes the window message from an iframe element to the background port", () => {
globalThis.dispatchEvent(
new MessageEvent("message", {
data: { command: "not-a-handled-command" },
source: autofillOverlayIframeService["iframe"].contentWindow,
origin: "chrome-extension://id",
}),
);
expect(portSpy.postMessage).toBeCalledWith({ command: "not-a-handled-command" });
});
it("updates the overlay list height", () => {
globalThis.dispatchEvent(
new MessageEvent("message", {
data: { command: "updateAutofillOverlayListHeight", styles: { height: "300px" } },
source: autofillOverlayIframeService["iframe"].contentWindow,
origin: "chrome-extension://id",
}),
);
expect(autofillOverlayIframeService["iframe"].style.height).toBe("300px");
});
describe("getPageColorScheme window message", () => {
afterEach(() => {
globalThis.document.head.innerHTML = "";
});
it("gets and updates the overlay page color scheme", () => {
const colorSchemeMetaTag = globalThis.document.createElement("meta");
colorSchemeMetaTag.setAttribute("name", "color-scheme");
colorSchemeMetaTag.setAttribute("content", "dark");
globalThis.document.head.append(colorSchemeMetaTag);
globalThis.dispatchEvent(
new MessageEvent("message", {
data: { command: "getPageColorScheme" },
source: autofillOverlayIframeService["iframe"].contentWindow,
origin: "chrome-extension://id",
}),
);
expect(autofillOverlayIframeService["iframe"].contentWindow.postMessage).toBeCalledWith(
{ command: "updateOverlayPageColorScheme", colorScheme: "dark" },
"*",
);
});
it("sends a normal color scheme if the color scheme meta tag is not present", () => {
globalThis.dispatchEvent(
new MessageEvent("message", {
data: { command: "getPageColorScheme" },
source: autofillOverlayIframeService["iframe"].contentWindow,
origin: "chrome-extension://id",
}),
);
expect(autofillOverlayIframeService["iframe"].contentWindow.postMessage).toBeCalledWith(
{ command: "updateOverlayPageColorScheme", colorScheme: "normal" },
"*",
);
});
});
});
});
describe("mutation observer", () => {
beforeEach(() => {
autofillOverlayIframeService.initOverlayIframe({ height: "0px" }, "title", "ariaAlert");
autofillOverlayIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD));
portSpy = autofillOverlayIframeService["port"];
});
it("skips handling found mutations if excessive mutations are triggering", async () => {
jest.useFakeTimers();
jest
.spyOn(
autofillOverlayIframeService as any,
"isTriggeringExcessiveMutationObserverIterations",
)
.mockReturnValue(true);
jest.spyOn(autofillOverlayIframeService as any, "updateElementStyles");
autofillOverlayIframeService["iframe"].style.visibility = "hidden";
await flushPromises();
expect(autofillOverlayIframeService["updateElementStyles"]).not.toBeCalled();
});
it("reverts any styles changes made directly to the iframe", async () => {
jest.useFakeTimers();
autofillOverlayIframeService["iframe"].style.visibility = "hidden";
await flushPromises();
expect(autofillOverlayIframeService["iframe"].style.visibility).toBe("visible");
});
it("force closes the autofill overlay if more than 9 foreign mutations are triggered", async () => {
jest.useFakeTimers();
autofillOverlayIframeService["foreignMutationsCount"] = 10;
autofillOverlayIframeService["iframe"].src = "http://malicious-site.com";
await flushPromises();
expect(portSpy.postMessage).toBeCalledWith({ command: "forceCloseAutofillOverlay" });
});
it("force closes the autofill overlay if excessive mutations are being triggered", async () => {
jest.useFakeTimers();
autofillOverlayIframeService["mutationObserverIterations"] = 20;
autofillOverlayIframeService["iframe"].src = "http://malicious-site.com";
await flushPromises();
expect(portSpy.postMessage).toBeCalledWith({ command: "forceCloseAutofillOverlay" });
});
it("resets the excessive mutations and foreign mutation counters", async () => {
jest.useFakeTimers();
autofillOverlayIframeService["foreignMutationsCount"] = 9;
autofillOverlayIframeService["mutationObserverIterations"] = 19;
autofillOverlayIframeService["iframe"].src = "http://malicious-site.com";
jest.advanceTimersByTime(2001);
await flushPromises();
expect(autofillOverlayIframeService["foreignMutationsCount"]).toBe(0);
expect(autofillOverlayIframeService["mutationObserverIterations"]).toBe(0);
});
it("resets any mutated default attributes for the iframe", async () => {
jest.useFakeTimers();
autofillOverlayIframeService["iframe"].title = "some-other-title";
await flushPromises();
expect(autofillOverlayIframeService["iframe"].title).toBe("title");
});
});
});

View File

@@ -1,429 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { EVENTS } from "@bitwarden/common/autofill/constants";
import { ThemeTypes } from "@bitwarden/common/platform/enums";
import { setElementStyles } from "../../../utils";
import {
BackgroundPortMessageHandlers,
AutofillOverlayIframeService as AutofillOverlayIframeServiceInterface,
AutofillOverlayIframeExtensionMessage,
AutofillOverlayIframeWindowMessageHandlers,
} from "../abstractions/autofill-overlay-iframe.service.deprecated";
class AutofillOverlayIframeService implements AutofillOverlayIframeServiceInterface {
private port: chrome.runtime.Port | null = null;
private extensionOriginsSet: Set<string>;
private iframeMutationObserver: MutationObserver;
private iframe: HTMLIFrameElement;
private ariaAlertElement: HTMLDivElement;
private ariaAlertTimeout: number | NodeJS.Timeout;
private iframeStyles: Partial<CSSStyleDeclaration> = {
all: "initial",
position: "fixed",
display: "block",
zIndex: "2147483647",
lineHeight: "0",
overflow: "hidden",
transition: "opacity 125ms ease-out 0s",
visibility: "visible",
clipPath: "none",
pointerEvents: "auto",
margin: "0",
padding: "0",
colorScheme: "normal",
opacity: "0",
};
private defaultIframeAttributes: Record<string, string> = {
src: "",
title: "",
sandbox: "allow-scripts",
allowtransparency: "true",
tabIndex: "-1",
};
private foreignMutationsCount = 0;
private mutationObserverIterations = 0;
private mutationObserverIterationsResetTimeout: number | NodeJS.Timeout;
private readonly windowMessageHandlers: AutofillOverlayIframeWindowMessageHandlers = {
updateAutofillOverlayListHeight: (message) =>
this.updateElementStyles(this.iframe, message.styles),
getPageColorScheme: () => this.updateOverlayPageColorScheme(),
};
private readonly backgroundPortMessageHandlers: BackgroundPortMessageHandlers = {
initAutofillOverlayList: ({ message }) => this.initAutofillOverlayList(message),
updateIframePosition: ({ message }) => this.updateIframePosition(message.styles),
updateOverlayHidden: ({ message }) => this.updateElementStyles(this.iframe, message.styles),
};
constructor(
private iframePath: string,
private portName: string,
private shadow: ShadowRoot,
) {
this.extensionOriginsSet = new Set([
chrome.runtime.getURL("").slice(0, -1).toLowerCase(), // Remove the trailing slash and normalize the extension url to lowercase
"null",
]);
this.iframeMutationObserver = new MutationObserver(this.handleMutations);
}
/**
* Handles initialization of the iframe which includes applying initial styles
* to the iframe, setting the source, and adding listener that connects the
* iframe to the background script each time it loads. Can conditionally
* create an aria alert element to announce to screen readers when the iframe
* is loaded. The end result is append to the shadowDOM of the custom element
* that is declared.
*
*
* @param initStyles - Initial styles to apply to the iframe
* @param iframeTitle - Title to apply to the iframe
* @param ariaAlert - Text to announce to screen readers when the iframe is loaded
*/
initOverlayIframe(
initStyles: Partial<CSSStyleDeclaration>,
iframeTitle: string,
ariaAlert?: string,
) {
this.defaultIframeAttributes.src = chrome.runtime.getURL(this.iframePath);
this.defaultIframeAttributes.title = iframeTitle;
this.iframe = globalThis.document.createElement("iframe");
this.updateElementStyles(this.iframe, { ...this.iframeStyles, ...initStyles });
for (const [attribute, value] of Object.entries(this.defaultIframeAttributes)) {
this.iframe.setAttribute(attribute, value);
}
this.iframe.addEventListener(EVENTS.LOAD, this.setupPortMessageListener);
if (ariaAlert) {
this.createAriaAlertElement(ariaAlert);
}
this.shadow.appendChild(this.iframe);
}
/**
* Creates an aria alert element that is used to announce to screen readers
* when the iframe is loaded.
*
* @param ariaAlertText - Text to announce to screen readers when the iframe is loaded
*/
private createAriaAlertElement(ariaAlertText: string) {
this.ariaAlertElement = globalThis.document.createElement("div");
this.ariaAlertElement.setAttribute("role", "status");
this.ariaAlertElement.setAttribute("aria-live", "polite");
this.ariaAlertElement.setAttribute("aria-atomic", "true");
this.updateElementStyles(this.ariaAlertElement, {
position: "absolute",
top: "-9999px",
left: "-9999px",
width: "1px",
height: "1px",
overflow: "hidden",
opacity: "0",
pointerEvents: "none",
});
this.ariaAlertElement.textContent = ariaAlertText;
}
/**
* Sets up the port message listener to the extension background script. This
* listener is used to communicate between the iframe and the background script.
* This also facilitates announcing to screen readers when the iframe is loaded.
*/
private setupPortMessageListener = () => {
this.port = chrome.runtime.connect({ name: this.portName });
this.port.onDisconnect.addListener(this.handlePortDisconnect);
this.port.onMessage.addListener(this.handlePortMessage);
globalThis.addEventListener(EVENTS.MESSAGE, this.handleWindowMessage);
this.announceAriaAlert();
};
/**
* Announces the aria alert element to screen readers when the iframe is loaded.
*/
private announceAriaAlert() {
if (!this.ariaAlertElement) {
return;
}
this.ariaAlertElement.remove();
if (this.ariaAlertTimeout) {
clearTimeout(this.ariaAlertTimeout);
}
this.ariaAlertTimeout = setTimeout(() => this.shadow.appendChild(this.ariaAlertElement), 2000);
}
/**
* Handles disconnecting the port message listener from the extension background
* script. This also removes the listener that facilitates announcing to screen
* readers when the iframe is loaded.
*
* @param port - The port that is disconnected
*/
private handlePortDisconnect = (port: chrome.runtime.Port) => {
if (port.name !== this.portName) {
return;
}
this.updateElementStyles(this.iframe, { opacity: "0", height: "0px", display: "block" });
globalThis.removeEventListener("message", this.handleWindowMessage);
this.unobserveIframe();
this.port?.onMessage.removeListener(this.handlePortMessage);
this.port?.onDisconnect.removeListener(this.handlePortDisconnect);
this.port?.disconnect();
this.port = null;
};
/**
* Handles messages sent from the extension background script to the iframe.
* Triggers behavior within the iframe as well as on the custom element that
* contains the iframe element.
*
* @param message
* @param port
*/
private handlePortMessage = (
message: AutofillOverlayIframeExtensionMessage,
port: chrome.runtime.Port,
) => {
if (port.name !== this.portName) {
return;
}
if (this.backgroundPortMessageHandlers[message.command]) {
this.backgroundPortMessageHandlers[message.command]({ message, port });
return;
}
this.iframe.contentWindow?.postMessage(message, "*");
};
/**
* Handles messages sent from the iframe to the extension background script.
* Will adjust the border element to fit the user's set theme.
*
* @param message - The message sent from the iframe
*/
private initAutofillOverlayList(message: AutofillOverlayIframeExtensionMessage) {
const { theme } = message;
let borderColor: string;
let verifiedTheme = theme;
if (verifiedTheme === ThemeTypes.System) {
verifiedTheme = globalThis.matchMedia("(prefers-color-scheme: dark)").matches
? ThemeTypes.Dark
: ThemeTypes.Light;
}
if (verifiedTheme === ThemeTypes.Dark) {
borderColor = "#4c525f";
}
if (borderColor) {
this.updateElementStyles(this.iframe, { borderColor });
}
message.theme = verifiedTheme;
this.iframe.contentWindow?.postMessage(message, "*");
}
/**
* Updates the position of the iframe element. Will also announce
* to screen readers that the iframe is open.
*
* @param position - The position styles to apply to the iframe
*/
private updateIframePosition(position: Partial<CSSStyleDeclaration>) {
if (!globalThis.document.hasFocus()) {
return;
}
this.updateElementStyles(this.iframe, position);
setTimeout(() => this.updateElementStyles(this.iframe, { opacity: "1" }), 0);
this.announceAriaAlert();
}
/**
* Gets the page color scheme meta tag and sends a message to the iframe
* to update its color scheme. Will default to "normal" if the meta tag
* does not exist.
*/
private updateOverlayPageColorScheme() {
const colorSchemeValue = globalThis.document
.querySelector("meta[name='color-scheme']")
?.getAttribute("content");
this.iframe.contentWindow?.postMessage(
{ command: "updateOverlayPageColorScheme", colorScheme: colorSchemeValue || "normal" },
"*",
);
}
/**
* Handles messages sent from the iframe. If the message does not have a
* specified handler set, it passes the message to the background script.
*
* @param event - The message event
*/
private handleWindowMessage = (event: MessageEvent) => {
if (
!this.port ||
event.source !== this.iframe.contentWindow ||
!this.isFromExtensionOrigin(event.origin.toLowerCase())
) {
return;
}
const message = event.data;
if (this.windowMessageHandlers[message.command]) {
this.windowMessageHandlers[message.command](message);
return;
}
this.port.postMessage(event.data);
};
/**
* Accepts an element and updates the styles for that element. This method
* will also unobserve the element if it is the iframe element. This is
* done to ensure that we do not trigger the mutation observer when we
* update the styles for the iframe.
*
* @param customElement - The element to update the styles for
* @param styles - The styles to apply to the element
*/
private updateElementStyles(customElement: HTMLElement, styles: Partial<CSSStyleDeclaration>) {
if (!customElement) {
return;
}
this.unobserveIframe();
setElementStyles(customElement, styles, true);
this.iframeStyles = { ...this.iframeStyles, ...styles };
this.observeIframe();
}
/**
* Chrome returns null for any sandboxed iframe sources.
* Firefox references the extension URI as its origin.
* Any other origin value is a security risk.
*
* @param messageOrigin - The origin of the window message
*/
private isFromExtensionOrigin(messageOrigin: string): boolean {
return this.extensionOriginsSet.has(messageOrigin);
}
/**
* Handles mutations to the iframe element. The ensures that the iframe
* element's styles are not modified by a third party source.
*
* @param mutations - The mutations to the iframe element
*/
private handleMutations = (mutations: MutationRecord[]) => {
if (this.isTriggeringExcessiveMutationObserverIterations()) {
return;
}
for (let index = 0; index < mutations.length; index++) {
const mutation = mutations[index];
if (mutation.type !== "attributes") {
continue;
}
const element = mutation.target as HTMLElement;
if (mutation.attributeName !== "style") {
this.handleElementAttributeMutation(element);
continue;
}
this.iframe.removeAttribute("style");
this.updateElementStyles(this.iframe, this.iframeStyles);
}
};
/**
* Handles mutations to the iframe element's attributes. This ensures that
* the iframe element's attributes are not modified by a third party source.
*
* @param element - The element to handle attribute mutations for
*/
private handleElementAttributeMutation(element: HTMLElement) {
const attributes = Array.from(element.attributes);
for (let attributeIndex = 0; attributeIndex < attributes.length; attributeIndex++) {
const attribute = attributes[attributeIndex];
if (attribute.name === "style") {
continue;
}
if (this.foreignMutationsCount >= 10) {
this.port?.postMessage({ command: "forceCloseAutofillOverlay" });
break;
}
const defaultIframeAttribute = this.defaultIframeAttributes[attribute.name];
if (!defaultIframeAttribute) {
this.iframe.removeAttribute(attribute.name);
this.foreignMutationsCount++;
continue;
}
if (attribute.value === defaultIframeAttribute) {
continue;
}
this.iframe.setAttribute(attribute.name, defaultIframeAttribute);
this.foreignMutationsCount++;
}
}
/**
* Observes the iframe element for mutations to its style attribute.
*/
private observeIframe() {
this.iframeMutationObserver.observe(this.iframe, { attributes: true });
}
/**
* Unobserves the iframe element for mutations to its style attribute.
*/
private unobserveIframe() {
this.iframeMutationObserver?.disconnect();
}
/**
* Identifies if the mutation observer is triggering excessive iterations.
* Will remove the autofill overlay if any set mutation observer is
* triggering excessive iterations.
*/
private isTriggeringExcessiveMutationObserverIterations() {
const resetCounters = () => {
this.mutationObserverIterations = 0;
this.foreignMutationsCount = 0;
};
if (this.mutationObserverIterationsResetTimeout) {
clearTimeout(this.mutationObserverIterationsResetTimeout);
}
this.mutationObserverIterations++;
this.mutationObserverIterationsResetTimeout = setTimeout(() => resetCounters(), 2000);
if (this.mutationObserverIterations > 20) {
clearTimeout(this.mutationObserverIterationsResetTimeout);
resetCounters();
this.port?.postMessage({ command: "forceCloseAutofillOverlay" });
return true;
}
return false;
}
}
export default AutofillOverlayIframeService;

View File

@@ -1,26 +0,0 @@
import AutofillOverlayListIframe from "./autofill-overlay-list-iframe.deprecated";
describe("AutofillOverlayListIframe", () => {
window.customElements.define(
"autofill-overlay-list-iframe",
class extends HTMLElement {
constructor() {
super();
new AutofillOverlayListIframe(this);
}
},
);
afterAll(() => {
jest.clearAllMocks();
});
it("creates a custom element that is an instance of the AutofillIframeElement parent class", () => {
document.body.innerHTML = "<autofill-overlay-list-iframe></autofill-overlay-list-iframe>";
const iframe = document.querySelector("autofill-overlay-list-iframe");
expect(iframe).toBeInstanceOf(HTMLElement);
expect(iframe.shadowRoot).toBeDefined();
});
});

View File

@@ -1,26 +0,0 @@
import { AutofillOverlayPort } from "../../../enums/autofill-overlay.enum";
import AutofillOverlayIframeElement from "./autofill-overlay-iframe-element.deprecated";
class AutofillOverlayListIframe extends AutofillOverlayIframeElement {
constructor(element: HTMLElement) {
super(
element,
"overlay/list.html",
AutofillOverlayPort.List,
{
height: "0px",
minWidth: "250px",
maxHeight: "180px",
boxShadow: "rgba(0, 0, 0, 0.1) 2px 4px 6px 0px",
borderRadius: "4px",
borderWidth: "1px",
borderStyle: "solid",
borderColor: "rgb(206, 212, 220)",
},
chrome.i18n.getMessage("bitwardenVault"),
);
}
}
export default AutofillOverlayListIframe;

View File

@@ -1,83 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AutofillOverlayButton initAutofillOverlayButton creates the button element with the locked icon when the user's auth status is not Unlocked 1`] = `
<button
aria-label="toggleBitwardenVaultOverlay"
class="overlay-button"
tabindex="-1"
type="button"
>
<svg
aria-hidden="true"
class="overlay-button-svg-icon logo-locked-icon"
fill="none"
height="16"
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M12.66.175A.566.566 0 0 0 12.25 0H1.75a.559.559 0 0 0-.409.175.561.561 0 0 0-.175.41v7c.002.532.105 1.06.305 1.554.189.488.444.948.756 1.368.322.42.682.81 1.076 1.163.365.335.75.649 1.152.939.35.248.718.483 1.103.706.385.222.656.372.815.45.16.08.29.141.386.182A.53.53 0 0 0 7 14a.509.509 0 0 0 .238-.055c.098-.043.225-.104.387-.182.162-.079.438-.23.816-.45.378-.222.75-.459 1.102-.707.403-.29.788-.604 1.154-.939a8.435 8.435 0 0 0 1.076-1.163c.312-.42.567-.88.757-1.367a4.19 4.19 0 0 0 .304-1.555v-7a.55.55 0 0 0-.174-.407Z"
fill="#175DDC"
/>
<path
d="M7 12.365s4.306-2.18 4.306-4.717V1.5H7v10.865Z"
fill="#fff"
/>
<circle
cx="12.889"
cy="12.889"
fill="#F8F9FA"
r="4.889"
/>
<path
d="M11.26 11.717h2.37v-.848c0-.313-.116-.58-.348-.8a1.17 1.17 0 0 0-.838-.332c-.327 0-.606.11-.838.332a1.066 1.066 0 0 0-.347.8v.848Zm3.851.424v2.546a.4.4 0 0 1-.13.3.44.44 0 0 1-.314.124h-4.445a.44.44 0 0 1-.315-.124.4.4 0 0 1-.13-.3V12.14a.4.4 0 0 1 .13-.3.44.44 0 0 1 .315-.124h.148v-.848c0-.542.204-1.008.612-1.397a2.044 2.044 0 0 1 1.462-.583c.568 0 1.056.194 1.463.583.408.39.611.855.611 1.397v.848h.149a.44.44 0 0 1 .315.124.4.4 0 0 1 .13.3Z"
fill="#555"
/>
</g>
<defs>
<clippath
id="a"
>
<rect
fill="#fff"
height="16"
rx="2"
width="16"
/>
</clippath>
</defs>
</svg>
</button>
`;
exports[`AutofillOverlayButton initAutofillOverlayButton creates the button element with the normal icon when the user's auth status is Unlocked 1`] = `
<button
aria-label="toggleBitwardenVaultOverlay"
class="overlay-button"
tabindex="-1"
type="button"
>
<svg
aria-hidden="true"
class="overlay-button-svg-icon logo-icon"
fill="none"
height="14"
viewBox="0 0 14 14"
width="14"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.66.175A.566.566 0 0 0 12.25 0H1.75a.559.559 0 0 0-.409.175.561.561 0 0 0-.175.41v7c.002.532.105 1.06.305 1.554.189.488.444.948.756 1.368.322.42.682.81 1.076 1.163.365.335.75.649 1.152.939.35.248.718.483 1.103.706.385.222.656.372.815.45.16.08.29.141.386.182A.53.53 0 0 0 7 14a.509.509 0 0 0 .238-.055c.098-.043.225-.104.387-.182.162-.079.438-.23.816-.45.378-.222.75-.459 1.102-.707.403-.29.788-.604 1.154-.939a8.435 8.435 0 0 0 1.076-1.163c.312-.42.567-.88.757-1.367a4.19 4.19 0 0 0 .304-1.555v-7a.55.55 0 0 0-.174-.407Z"
fill="#175DDC"
/>
<path
d="M7 12.365s4.306-2.18 4.306-4.717V1.5H7v10.865Z"
fill="#fff"
/>
</svg>
</button>
`;

View File

@@ -1,135 +0,0 @@
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { postWindowMessage } from "../../../../spec/testing-utils";
import { InitAutofillOverlayButtonMessage } from "../../abstractions/autofill-overlay-button.deprecated";
import AutofillOverlayButton from "./autofill-overlay-button.deprecated";
const overlayPagesTranslations = {
locale: "en",
buttonPageTitle: "buttonPageTitle",
listPageTitle: "listPageTitle",
opensInANewWindow: "opensInANewWindow",
toggleBitwardenVaultOverlay: "toggleBitwardenVaultOverlay",
unlockYourAccount: "unlockYourAccount",
unlockAccount: "unlockAccount",
fillCredentialsFor: "fillCredentialsFor",
partialUsername: "partialUsername",
view: "view",
noItemsToShow: "noItemsToShow",
newItem: "newItem",
addNewVaultItem: "addNewVaultItem",
};
function createInitAutofillOverlayButtonMessageMock(
customFields = {},
): InitAutofillOverlayButtonMessage {
return {
command: "initAutofillOverlayButton",
translations: overlayPagesTranslations,
styleSheetUrl: "https://jest-testing-website.com",
authStatus: AuthenticationStatus.Unlocked,
...customFields,
};
}
describe("AutofillOverlayButton", () => {
globalThis.customElements.define("autofill-overlay-button", AutofillOverlayButton);
let autofillOverlayButton: AutofillOverlayButton;
beforeEach(() => {
document.body.innerHTML = `<autofill-overlay-button></autofill-overlay-button>`;
autofillOverlayButton = document.querySelector("autofill-overlay-button");
autofillOverlayButton["messageOrigin"] = "https://localhost/";
jest.spyOn(globalThis.document, "createElement");
jest.spyOn(globalThis.parent, "postMessage");
});
afterEach(() => {
jest.clearAllMocks();
});
describe("initAutofillOverlayButton", () => {
it("creates the button element with the locked icon when the user's auth status is not Unlocked", () => {
postWindowMessage(
createInitAutofillOverlayButtonMessageMock({ authStatus: AuthenticationStatus.Locked }),
);
expect(autofillOverlayButton["buttonElement"]).toMatchSnapshot();
expect(autofillOverlayButton["buttonElement"].querySelector("svg")).toBe(
autofillOverlayButton["logoLockedIconElement"],
);
});
it("creates the button element with the normal icon when the user's auth status is Unlocked ", () => {
postWindowMessage(createInitAutofillOverlayButtonMessageMock());
expect(autofillOverlayButton["buttonElement"]).toMatchSnapshot();
expect(autofillOverlayButton["buttonElement"].querySelector("svg")).toBe(
autofillOverlayButton["logoIconElement"],
);
});
it("posts a message to the background indicating that the icon was clicked", () => {
postWindowMessage(createInitAutofillOverlayButtonMessageMock());
autofillOverlayButton["buttonElement"].click();
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "overlayButtonClicked" },
"https://localhost/",
);
});
});
describe("global event listeners", () => {
beforeEach(() => {
postWindowMessage(createInitAutofillOverlayButtonMessageMock());
});
it("does not post a message to close the autofill overlay if the element is focused during the focus check", () => {
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
postWindowMessage({ command: "checkAutofillOverlayButtonFocused" });
expect(globalThis.parent.postMessage).not.toHaveBeenCalledWith({
command: "closeAutofillOverlay",
});
});
it("posts a message to close the autofill overlay if the element is not focused during the focus check", () => {
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(false);
postWindowMessage({ command: "checkAutofillOverlayButtonFocused" });
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "closeAutofillOverlay" },
"https://localhost/",
);
});
it("updates the user's auth status", () => {
autofillOverlayButton["authStatus"] = AuthenticationStatus.Locked;
postWindowMessage({
command: "updateAutofillOverlayButtonAuthStatus",
authStatus: AuthenticationStatus.Unlocked,
});
expect(autofillOverlayButton["authStatus"]).toBe(AuthenticationStatus.Unlocked);
});
it("updates the page color scheme meta tag", () => {
const colorSchemeMetaTag = globalThis.document.createElement("meta");
colorSchemeMetaTag.setAttribute("name", "color-scheme");
colorSchemeMetaTag.setAttribute("content", "light");
globalThis.document.head.append(colorSchemeMetaTag);
postWindowMessage({
command: "updateOverlayPageColorScheme",
colorScheme: "dark",
});
expect(colorSchemeMetaTag.getAttribute("content")).toBe("dark");
});
});
});

View File

@@ -1,124 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import "@webcomponents/custom-elements";
import "lit/polyfill-support.js";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { EVENTS } from "@bitwarden/common/autofill/constants";
import { buildSvgDomElement } from "../../../../utils";
import { logoIcon, logoLockedIcon } from "../../../../utils/svg-icons";
import {
InitAutofillOverlayButtonMessage,
OverlayButtonMessage,
OverlayButtonWindowMessageHandlers,
} from "../../abstractions/autofill-overlay-button.deprecated";
import AutofillOverlayPageElement from "../shared/autofill-overlay-page-element.deprecated";
class AutofillOverlayButton extends AutofillOverlayPageElement {
private authStatus: AuthenticationStatus = AuthenticationStatus.LoggedOut;
private readonly buttonElement: HTMLButtonElement;
private readonly logoIconElement: HTMLElement;
private readonly logoLockedIconElement: HTMLElement;
private readonly overlayButtonWindowMessageHandlers: OverlayButtonWindowMessageHandlers = {
initAutofillOverlayButton: ({ message }) => this.initAutofillOverlayButton(message),
checkAutofillOverlayButtonFocused: () => this.checkButtonFocused(),
updateAutofillOverlayButtonAuthStatus: ({ message }) =>
this.updateAuthStatus(message.authStatus),
updateOverlayPageColorScheme: ({ message }) => this.updatePageColorScheme(message),
};
constructor() {
super();
this.buttonElement = globalThis.document.createElement("button");
this.setupGlobalListeners(this.overlayButtonWindowMessageHandlers);
this.logoIconElement = buildSvgDomElement(logoIcon);
this.logoIconElement.classList.add("overlay-button-svg-icon", "logo-icon");
this.logoLockedIconElement = buildSvgDomElement(logoLockedIcon);
this.logoLockedIconElement.classList.add("overlay-button-svg-icon", "logo-locked-icon");
}
/**
* Initializes the overlay button. Facilitates ensuring that the page
* is set up with the expected styles and translations.
*
* @param authStatus - The authentication status of the user
* @param styleSheetUrl - The URL of the stylesheet to apply to the page
* @param translations - The translations to apply to the page
* @private
*/
private async initAutofillOverlayButton({
authStatus,
styleSheetUrl,
translations,
}: InitAutofillOverlayButtonMessage) {
const linkElement = this.initOverlayPage("button", styleSheetUrl, translations);
this.buttonElement.tabIndex = -1;
this.buttonElement.type = "button";
this.buttonElement.classList.add("overlay-button");
this.buttonElement.setAttribute(
"aria-label",
this.getTranslation("toggleBitwardenVaultOverlay"),
);
this.buttonElement.addEventListener(EVENTS.CLICK, this.handleButtonElementClick);
this.postMessageToParent({ command: "getPageColorScheme" });
this.updateAuthStatus(authStatus);
this.shadowDom.append(linkElement, this.buttonElement);
}
/**
* Updates the authentication status of the user. This will update the icon
* displayed on the button.
*
* @param authStatus - The authentication status of the user
*/
private updateAuthStatus(authStatus: AuthenticationStatus) {
this.authStatus = authStatus;
this.buttonElement.innerHTML = "";
const iconElement =
this.authStatus === AuthenticationStatus.Unlocked
? this.logoIconElement
: this.logoLockedIconElement;
this.buttonElement.append(iconElement);
}
/**
* Handles updating the page color scheme meta tag. Ensures that the button
* does not present with a non-transparent background on dark mode pages.
*
* @param colorScheme - The color scheme of the iframe's parent page
*/
private updatePageColorScheme({ colorScheme }: OverlayButtonMessage) {
const colorSchemeMetaTag = globalThis.document.querySelector("meta[name='color-scheme']");
colorSchemeMetaTag?.setAttribute("content", colorScheme);
}
/**
* Handles a click event on the button element. Posts a message to the
* parent window indicating that the button was clicked.
*/
private handleButtonElementClick = () => {
this.postMessageToParent({ command: "overlayButtonClicked" });
};
/**
* Checks if the button is focused. If it is not, then it posts a message
* to the parent window indicating that the overlay should be closed.
*/
private checkButtonFocused() {
if (globalThis.document.hasFocus()) {
return;
}
this.postMessageToParent({ command: "closeAutofillOverlay" });
}
}
export default AutofillOverlayButton;

View File

@@ -1,11 +0,0 @@
import { AutofillOverlayElement } from "../../../../enums/autofill-overlay.enum";
import AutofillOverlayButton from "./autofill-overlay-button.deprecated";
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("./legacy-button.scss");
(function () {
globalThis.customElements.define(AutofillOverlayElement.Button, AutofillOverlayButton);
})();

View File

@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<title>Bitwarden overlay button</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="normal" />
</head>
<body>
<autofill-inline-menu-button></autofill-inline-menu-button>
</body>
</html>

View File

@@ -1,36 +0,0 @@
@import "../../../../shared/styles/variables";
* {
box-sizing: border-box;
}
body {
width: 100%;
min-width: 100vw;
height: 100%;
min-height: 100vh;
padding: 0;
margin: 0;
background: transparent;
overflow: hidden;
}
autofill-overlay-button {
width: 100%;
height: auto;
}
.overlay-button {
display: block;
width: 100%;
padding: 0;
margin: auto;
border: none;
background: transparent;
cursor: pointer;
.overlay-button-svg-icon {
display: block;
width: 100%;
height: auto;
}
}

View File

@@ -1,537 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AutofillOverlayList initAutofillOverlayList the list of ciphers for an authenticated user creates the view for a list of ciphers 1`] = `
<div
class="overlay-list-container theme_light"
>
<ul
class="overlay-actions-list"
role="list"
>
<li
class="overlay-actions-list-item"
role="listitem"
>
<div
class="cipher-container"
>
<button
aria-description="partialUsername, username1"
aria-label="fillCredentialsFor website login 1"
class="fill-cipher-button"
tabindex="-1"
>
<span
aria-hidden="true"
class="cipher-icon"
style="background-image: url(https://jest-testing-website.com/image.png);"
/>
<span
class="cipher-details"
>
<span
class="cipher-name"
title="website login 1"
>
website login 1
</span>
<span
class="cipher-user-login"
title="username1"
>
username1
</span>
</span>
</button>
<button
aria-label="view website login 1, opensInANewWindow"
class="view-cipher-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .113h20v19.773H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
</div>
</li>
<li
class="overlay-actions-list-item"
role="listitem"
>
<div
class="cipher-container"
>
<button
aria-description="partialUsername, username2"
aria-label="fillCredentialsFor website login 2"
class="fill-cipher-button"
tabindex="-1"
>
<span
aria-hidden="true"
class="cipher-icon bwi bw-icon"
/>
<span
class="cipher-details"
>
<span
class="cipher-name"
title="website login 2"
>
website login 2
</span>
<span
class="cipher-user-login"
title="username2"
>
username2
</span>
</span>
</button>
<button
aria-label="view website login 2, opensInANewWindow"
class="view-cipher-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .113h20v19.773H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
</div>
</li>
<li
class="overlay-actions-list-item"
role="listitem"
>
<div
class="cipher-container"
>
<button
aria-description="partialUsername, "
aria-label="fillCredentialsFor "
class="fill-cipher-button"
tabindex="-1"
>
<span
aria-hidden="true"
class="cipher-icon bwi bw-icon"
/>
<span
class="cipher-details"
/>
</button>
<button
aria-label="view , opensInANewWindow"
class="view-cipher-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .113h20v19.773H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
</div>
</li>
<li
class="overlay-actions-list-item"
role="listitem"
>
<div
class="cipher-container"
>
<button
aria-description="partialUsername, username4"
aria-label="fillCredentialsFor website login 4"
class="fill-cipher-button"
tabindex="-1"
>
<span
aria-hidden="true"
class="cipher-icon"
>
<svg
aria-hidden="true"
fill="none"
height="25"
viewBox="0 0 24 25"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M18.026 17.842c-1.418 1.739-3.517 2.84-5.86 2.84a7.364 7.364 0 0 1-3.431-.848l.062-.15.062-.151.063-.157c.081-.203.17-.426.275-.646.133-.28.275-.522.426-.68.026-.028.101-.075.275-.115.165-.037.376-.059.629-.073.138-.008.288-.014.447-.02.399-.016.847-.034 1.266-.092.314-.044.566-.131.755-.271a.884.884 0 0 0 .352-.555c.037-.2.008-.392-.03-.543-.035-.135-.084-.264-.12-.355l-.01-.03a4.26 4.26 0 0 0-.145-.33c-.126-.264-.237-.497-.288-1.085-.03-.344.09-.73.251-1.138l.089-.22c.05-.123.1-.247.14-.355.064-.171.129-.375.129-.566a1.51 1.51 0 0 0-.134-.569 2.573 2.573 0 0 0-.319-.547c-.246-.323-.635-.669-1.093-.669-.44 0-1.006.169-1.487.368-.246.102-.48.216-.68.33-.192.111-.372.235-.492.359-.93.96-1.48 1.239-1.81 1.258-.277.017-.478-.15-.736-.525a9.738 9.738 0 0 1-.19-.29l-.006-.01a11.568 11.568 0 0 0-.198-.305 2.76 2.76 0 0 0-.521-.6 1.39 1.39 0 0 0-1.088-.314 8.302 8.302 0 0 1 1.987-3.936c.055.342.146.626.272.856.23.42.561.64.926.716.406.086.857-.061 1.26-.216.125-.047.248-.097.372-.147.309-.125.618-.25.947-.341.26-.072.581-.057.959.012.264.049.529.118.8.19l.36.091c.379.094.782.178 1.135.148.374-.032.733-.197.934-.623a.874.874 0 0 0 .024-.752c-.087-.197-.24-.355-.35-.47-.26-.267-.412-.427-.412-.685 0-.125.037-.2.09-.263a.982.982 0 0 1 .303-.211c.059-.03.119-.058.183-.089l.036-.016a3.79 3.79 0 0 0 .236-.118c.047-.026.098-.056.148-.093 1.936.747 3.51 2.287 4.368 4.249a7.739 7.739 0 0 0-.031-.004c-.38-.047-.738-.056-1.063.061-.34.123-.603.368-.817.74-.122.211-.284.43-.463.67l-.095.129c-.207.281-.431.595-.58.92-.15.326-.245.705-.142 1.103.104.397.387.738.837 1.036.099.065.225.112.314.145l.02.008c.108.04.195.074.268.117.07.042.106.08.124.114.017.03.037.087.022.206-.047.376-.069.73-.052 1.034.017.292.071.59.218.809.118.174.12.421.108.786v.01a2.46 2.46 0 0 0 .021.518.809.809 0 0 0 .15.35Zm1.357.059a9.654 9.654 0 0 0 1.62-5.386c0-5.155-3.957-9.334-8.836-9.334-4.88 0-8.836 4.179-8.836 9.334 0 3.495 1.82 6.543 4.513 8.142v.093h.161a8.426 8.426 0 0 0 4.162 1.098c2.953 0 5.568-1.53 7.172-3.882a.569.569 0 0 0 .048-.062l-.004-.003ZM8.152 19.495a43.345 43.345 0 0 1 .098-.238l.057-.142c.082-.205.182-.455.297-.698.143-.301.323-.624.552-.864.163-.172.392-.254.602-.302.219-.05.473-.073.732-.088.162-.01.328-.016.495-.023.386-.015.782-.03 1.168-.084.255-.036.392-.099.461-.15.06-.045.076-.084.083-.12a.534.534 0 0 0-.02-.223 2.552 2.552 0 0 0-.095-.278l-.01-.027a3.128 3.128 0 0 0-.104-.232c-.134-.282-.31-.65-.374-1.381-.046-.533.138-1.063.3-1.472.035-.09.069-.172.1-.249.046-.11.086-.21.123-.31.062-.169.083-.264.083-.312a.812.812 0 0 0-.076-.283 1.867 1.867 0 0 0-.23-.394c-.21-.274-.428-.408-.577-.408-.315 0-.788.13-1.246.32a5.292 5.292 0 0 0-.603.293 1.727 1.727 0 0 0-.347.244c-.936.968-1.641 1.421-2.235 1.457-.646.04-1.036-.413-1.31-.813-.07-.103-.139-.21-.203-.311l-.005-.007c-.064-.101-.125-.197-.188-.29a2.098 2.098 0 0 0-.387-.453.748.748 0 0 0-.436-.18c-.1-.006-.22.005-.365.046a8.707 8.707 0 0 0-.056.992c0 2.957 1.488 5.547 3.716 6.98Zm10.362-2.316.003-.192.002-.046c.01-.305.026-.786-.232-1.169-.036-.054-.082-.189-.096-.444-.014-.243.003-.55.047-.9a1.051 1.051 0 0 0-.105-.649.987.987 0 0 0-.374-.374 2.285 2.285 0 0 0-.367-.166h-.003a1.243 1.243 0 0 1-.205-.088c-.369-.244-.505-.46-.549-.629-.044-.168-.015-.364.099-.61.115-.25.297-.511.507-.796l.089-.12c.178-.239.368-.495.512-.745.152-.263.302-.382.466-.441.18-.065.416-.073.77-.03.142.018.275.04.397.063.274.837.423 1.736.423 2.671a8.45 8.45 0 0 1-1.384 4.665Zm-4.632-12.63a7.362 7.362 0 0 0-1.715-.201c-1.89 0-3.621.716-4.965 1.905.025.54.12.887.24 1.105.13.238.295.34.482.38.2.042.484-.027.905-.188l.328-.13c.32-.13.681-.275 1.048-.377.398-.111.833-.075 1.24 0 .289.053.59.132.871.205l.326.084c.383.094.694.151.932.13.216-.017.326-.092.395-.237.039-.083.027-.114.014-.144-.027-.062-.088-.136-.212-.264l-.043-.044c-.218-.222-.567-.578-.567-1.142 0-.305.101-.547.262-.734.137-.159.308-.267.46-.347Z"
fill="#777"
fill-rule="evenodd"
/>
</svg>
</span>
<span
class="cipher-details"
>
<span
class="cipher-name"
title="website login 4"
>
website login 4
</span>
<span
class="cipher-user-login"
title="username4"
>
username4
</span>
</span>
</button>
<button
aria-label="view website login 4, opensInANewWindow"
class="view-cipher-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .113h20v19.773H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
</div>
</li>
<li
class="overlay-actions-list-item"
role="listitem"
>
<div
class="cipher-container"
>
<button
aria-description="partialUsername, username5"
aria-label="fillCredentialsFor website login 5"
class="fill-cipher-button"
tabindex="-1"
>
<span
aria-hidden="true"
class="cipher-icon"
style="background-image: url(https://jest-testing-website.com/image.png);"
/>
<span
class="cipher-details"
>
<span
class="cipher-name"
title="website login 5"
>
website login 5
</span>
<span
class="cipher-user-login"
title="username5"
>
username5
</span>
</span>
</button>
<button
aria-label="view website login 5, opensInANewWindow"
class="view-cipher-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .113h20v19.773H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
</div>
</li>
<li
class="overlay-actions-list-item"
role="listitem"
>
<div
class="cipher-container"
>
<button
aria-description="partialUsername, username6"
aria-label="fillCredentialsFor website login 6"
class="fill-cipher-button"
tabindex="-1"
>
<span
aria-hidden="true"
class="cipher-icon"
style="background-image: url(https://jest-testing-website.com/image.png);"
/>
<span
class="cipher-details"
>
<span
class="cipher-name"
title="website login 6"
>
website login 6
</span>
<span
class="cipher-user-login"
title="username6"
>
username6
</span>
</span>
</button>
<button
aria-label="view website login 6, opensInANewWindow"
class="view-cipher-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .113h20v19.773H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
</div>
</li>
</ul>
</div>
`;
exports[`AutofillOverlayList initAutofillOverlayList the locked overlay for an unauthenticated user creates the views for the locked overlay 1`] = `
<div
class="overlay-list-container theme_light"
>
<div
class="locked-overlay overlay-list-message"
id="locked-overlay-description"
>
unlockYourAccount
</div>
<div
class="overlay-list-button-container"
>
<button
aria-label="unlockAccount, opensInANewWindow"
class="unlock-button overlay-list-button"
id="unlock-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="17"
viewBox="0 0 17 17"
width="17"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M8.799 11.633a.68.68 0 0 0-.639.422.695.695 0 0 0-.054.264.682.682 0 0 0 .374.6v1.13a.345.345 0 1 0 .693 0v-1.17a.68.68 0 0 0 .315-.56.695.695 0 0 0-.204-.486.682.682 0 0 0-.485-.2Zm4.554-4.657h-7.11a.438.438 0 0 1-.406-.26A3.81 3.81 0 0 1 5.584 4.3c.112-.435.312-.842.588-1.195A3.196 3.196 0 0 1 7.19 2.25a3.468 3.468 0 0 1 3.225-.059A3.62 3.62 0 0 1 11.94 3.71l.327.59a.502.502 0 1 0 .885-.483l-.307-.552a4.689 4.689 0 0 0-2.209-2.078 4.466 4.466 0 0 0-3.936.185A4.197 4.197 0 0 0 5.37 2.49a4.234 4.234 0 0 0-.768 1.565 4.714 4.714 0 0 0 .162 2.682.182.182 0 0 1-.085.22.173.173 0 0 1-.082.02h-.353a1.368 1.368 0 0 0-1.277.842c-.07.168-.107.348-.109.53v7.1a1.392 1.392 0 0 0 .412.974 1.352 1.352 0 0 0 .974.394h9.117c.363.001.711-.142.97-.4a1.39 1.39 0 0 0 .407-.972v-7.1a1.397 1.397 0 0 0-.414-.973 1.368 1.368 0 0 0-.972-.396Zm.37 8.469a.373.373 0 0 1-.11.26.364.364 0 0 1-.26.107H4.246a.366.366 0 0 1-.26-.107.374.374 0 0 1-.11-.261V8.349a.374.374 0 0 1 .11-.26.366.366 0 0 1 .26-.108h9.116a.366.366 0 0 1 .37.367l-.008 7.097Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M.798.817h16v16h-16z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
unlockAccount
</button>
</div>
</div>
`;
exports[`AutofillOverlayList initAutofillOverlayList the overlay with an empty list of ciphers creates the views for the no results overlay 1`] = `
<div
class="overlay-list-container theme_light"
>
<div
class="no-items overlay-list-message"
>
noItemsToShow
</div>
<div
class="overlay-list-button-container"
>
<button
aria-label="addNewVaultItem, opensInANewWindow"
class="add-new-item-button overlay-list-button"
id="new-item-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="17"
width="17"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
clip-rule="evenodd"
d="M9.607 7.15h5.35c.322 0 .627.133.847.362a1.213 1.213 0 0 1 .002 1.68c-.221.23-.527.363-.85.363H9.607v5.652c0 .312-.12.613-.336.839a1.176 1.176 0 0 1-1.696.003 1.21 1.21 0 0 1-.34-.842V9.555H1.888a1.173 1.173 0 0 1-.847-.361A1.193 1.193 0 0 1 .7 8.352a1.219 1.219 0 0 1 .336-.838 1.175 1.175 0 0 1 .85-.364h5.349V1.635c0-.31.118-.611.336-.84A1.176 1.176 0 0 1 9.268.795c.222.228.34.533.34.841V7.15Z"
fill="#175DDC"
fill-rule="evenodd"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M.421.421h16v16h-16z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
newItem
</button>
</div>
</div>
`;

View File

@@ -1,467 +0,0 @@
import { mock } from "jest-mock-extended";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { createAutofillOverlayCipherDataMock } from "../../../../spec/autofill-mocks";
import { postWindowMessage } from "../../../../spec/testing-utils";
import { InitAutofillOverlayListMessage } from "../../abstractions/autofill-overlay-list.deprecated";
import AutofillOverlayList from "./autofill-overlay-list.deprecated";
const overlayPagesTranslations = {
locale: "en",
buttonPageTitle: "buttonPageTitle",
listPageTitle: "listPageTitle",
opensInANewWindow: "opensInANewWindow",
toggleBitwardenVaultOverlay: "toggleBitwardenVaultOverlay",
unlockYourAccount: "unlockYourAccount",
unlockAccount: "unlockAccount",
fillCredentialsFor: "fillCredentialsFor",
partialUsername: "partialUsername",
view: "view",
noItemsToShow: "noItemsToShow",
newItem: "newItem",
addNewVaultItem: "addNewVaultItem",
};
function createInitAutofillOverlayListMessageMock(
customFields = {},
): InitAutofillOverlayListMessage {
return {
command: "initAutofillOverlayList",
translations: overlayPagesTranslations,
styleSheetUrl: "https://jest-testing-website.com",
theme: ThemeType.Light,
authStatus: AuthenticationStatus.Unlocked,
ciphers: [
createAutofillOverlayCipherDataMock(1, {
icon: {
imageEnabled: true,
image: "https://jest-testing-website.com/image.png",
fallbackImage: "",
icon: "bw-icon",
},
}),
createAutofillOverlayCipherDataMock(2, {
icon: {
imageEnabled: true,
image: "",
fallbackImage: "https://jest-testing-website.com/fallback.png",
icon: "bw-icon",
},
}),
createAutofillOverlayCipherDataMock(3, {
name: "",
login: { username: "" },
icon: { imageEnabled: true, image: "", fallbackImage: "", icon: "bw-icon" },
}),
createAutofillOverlayCipherDataMock(4, {
icon: { imageEnabled: false, image: "", fallbackImage: "", icon: "" },
}),
createAutofillOverlayCipherDataMock(5),
createAutofillOverlayCipherDataMock(6),
createAutofillOverlayCipherDataMock(7),
createAutofillOverlayCipherDataMock(8),
],
...customFields,
};
}
describe("AutofillOverlayList", () => {
globalThis.customElements.define("autofill-overlay-list", AutofillOverlayList);
global.ResizeObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}));
let autofillOverlayList: AutofillOverlayList;
beforeEach(() => {
document.body.innerHTML = `<autofill-overlay-list></autofill-overlay-list>`;
autofillOverlayList = document.querySelector("autofill-overlay-list");
autofillOverlayList["messageOrigin"] = "https://localhost/";
jest.spyOn(globalThis.document, "createElement");
jest.spyOn(globalThis.parent, "postMessage");
});
afterEach(() => {
jest.clearAllMocks();
});
describe("initAutofillOverlayList", () => {
describe("the locked overlay for an unauthenticated user", () => {
beforeEach(() => {
postWindowMessage(
createInitAutofillOverlayListMessageMock({
authStatus: AuthenticationStatus.Locked,
cipherList: [],
}),
);
});
it("creates the views for the locked overlay", () => {
expect(autofillOverlayList["overlayListContainer"]).toMatchSnapshot();
});
it("allows the user to unlock the vault", () => {
const unlockButton =
autofillOverlayList["overlayListContainer"].querySelector("#unlock-button");
unlockButton.dispatchEvent(new Event("click"));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "unlockVault" },
"https://localhost/",
);
});
});
describe("the overlay with an empty list of ciphers", () => {
beforeEach(() => {
postWindowMessage(
createInitAutofillOverlayListMessageMock({
authStatus: AuthenticationStatus.Unlocked,
ciphers: [],
}),
);
});
it("creates the views for the no results overlay", () => {
expect(autofillOverlayList["overlayListContainer"]).toMatchSnapshot();
});
it("allows the user to add a vault item", () => {
const addVaultItemButton =
autofillOverlayList["overlayListContainer"].querySelector("#new-item-button");
addVaultItemButton.dispatchEvent(new Event("click"));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "addNewVaultItem" },
"https://localhost/",
);
});
});
describe("the list of ciphers for an authenticated user", () => {
beforeEach(() => {
postWindowMessage(createInitAutofillOverlayListMessageMock());
});
it("creates the view for a list of ciphers", () => {
expect(autofillOverlayList["overlayListContainer"]).toMatchSnapshot();
});
it("loads ciphers on scroll one page at a time", () => {
jest.useFakeTimers();
const originalListOfElements =
autofillOverlayList["overlayListContainer"].querySelectorAll(".cipher-container");
autofillOverlayList["handleCiphersListScrollEvent"]();
jest.runAllTimers();
const updatedListOfElements =
autofillOverlayList["overlayListContainer"].querySelectorAll(".cipher-container");
expect(originalListOfElements.length).toBe(6);
expect(updatedListOfElements.length).toBe(8);
});
it("debounces the ciphers scroll handler", () => {
jest.useFakeTimers();
autofillOverlayList["cipherListScrollDebounceTimeout"] = setTimeout(jest.fn, 0);
const handleDebouncedScrollEventSpy = jest.spyOn(
autofillOverlayList as any,
"handleDebouncedScrollEvent",
);
autofillOverlayList["handleCiphersListScrollEvent"]();
jest.advanceTimersByTime(100);
autofillOverlayList["handleCiphersListScrollEvent"]();
jest.advanceTimersByTime(100);
autofillOverlayList["handleCiphersListScrollEvent"]();
jest.advanceTimersByTime(400);
expect(handleDebouncedScrollEventSpy).toHaveBeenCalledTimes(1);
});
describe("fill cipher button event listeners", () => {
beforeEach(() => {
postWindowMessage(createInitAutofillOverlayListMessageMock());
});
it("allows the user to fill a cipher on click", () => {
const fillCipherButton =
autofillOverlayList["overlayListContainer"].querySelector(".fill-cipher-button");
fillCipherButton.dispatchEvent(new Event("click"));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "fillSelectedListItem", overlayCipherId: "1" },
"https://localhost/",
);
});
it("allows the user to move keyboard focus to the next cipher element on ArrowDown", () => {
const fillCipherElements =
autofillOverlayList["overlayListContainer"].querySelectorAll(".fill-cipher-button");
const firstFillCipherElement = fillCipherElements[0];
const secondFillCipherElement = fillCipherElements[1];
jest.spyOn(secondFillCipherElement as HTMLElement, "focus");
firstFillCipherElement.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowDown" }));
expect((secondFillCipherElement as HTMLElement).focus).toBeCalled();
});
it("directs focus to the first item in the cipher list if no cipher is present after the current one when pressing ArrowDown", () => {
const fillCipherElements =
autofillOverlayList["overlayListContainer"].querySelectorAll(".fill-cipher-button");
const lastFillCipherElement = fillCipherElements[fillCipherElements.length - 1];
const firstFillCipherElement = fillCipherElements[0];
jest.spyOn(firstFillCipherElement as HTMLElement, "focus");
lastFillCipherElement.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowDown" }));
expect((firstFillCipherElement as HTMLElement).focus).toBeCalled();
});
it("allows the user to move keyboard focus to the previous cipher element on ArrowUp", () => {
const fillCipherElements =
autofillOverlayList["overlayListContainer"].querySelectorAll(".fill-cipher-button");
const firstFillCipherElement = fillCipherElements[0];
const secondFillCipherElement = fillCipherElements[1];
jest.spyOn(firstFillCipherElement as HTMLElement, "focus");
secondFillCipherElement.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowUp" }));
expect((firstFillCipherElement as HTMLElement).focus).toBeCalled();
});
it("directs focus to the last item in the cipher list if no cipher is present before the current one when pressing ArrowUp", () => {
const fillCipherElements =
autofillOverlayList["overlayListContainer"].querySelectorAll(".fill-cipher-button");
const firstFillCipherElement = fillCipherElements[0];
const lastFillCipherElement = fillCipherElements[fillCipherElements.length - 1];
jest.spyOn(lastFillCipherElement as HTMLElement, "focus");
firstFillCipherElement.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowUp" }));
expect((lastFillCipherElement as HTMLElement).focus).toBeCalled();
});
it("allows the user to move keyboard focus to the view cipher button on ArrowRight", () => {
const cipherContainerElement =
autofillOverlayList["overlayListContainer"].querySelector(".cipher-container");
const fillCipherElement = cipherContainerElement.querySelector(".fill-cipher-button");
const viewCipherButton = cipherContainerElement.querySelector(".view-cipher-button");
jest.spyOn(viewCipherButton as HTMLElement, "focus");
fillCipherElement.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowRight" }));
expect((viewCipherButton as HTMLElement).focus).toBeCalled();
});
it("ignores keyup events that do not include ArrowUp, ArrowDown, or ArrowRight", () => {
const fillCipherElement =
autofillOverlayList["overlayListContainer"].querySelector(".fill-cipher-button");
jest.spyOn(fillCipherElement as HTMLElement, "focus");
fillCipherElement.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowLeft" }));
expect((fillCipherElement as HTMLElement).focus).not.toBeCalled();
});
});
describe("view cipher button event listeners", () => {
beforeEach(() => {
postWindowMessage(createInitAutofillOverlayListMessageMock());
});
it("allows the user to view a cipher on click", () => {
const viewCipherButton =
autofillOverlayList["overlayListContainer"].querySelector(".view-cipher-button");
viewCipherButton.dispatchEvent(new Event("click"));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "viewSelectedCipher", overlayCipherId: "1" },
"https://localhost/",
);
});
it("allows the user to move keyboard focus to the current cipher element on ArrowLeft", () => {
const cipherContainerElement =
autofillOverlayList["overlayListContainer"].querySelector(".cipher-container");
const fillCipherButton = cipherContainerElement.querySelector(".fill-cipher-button");
const viewCipherButton = cipherContainerElement.querySelector(".view-cipher-button");
jest.spyOn(fillCipherButton as HTMLElement, "focus");
viewCipherButton.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowLeft" }));
expect((fillCipherButton as HTMLElement).focus).toBeCalled();
});
it("allows the user to move keyboard to the next cipher element on ArrowDown", () => {
const cipherContainerElements =
autofillOverlayList["overlayListContainer"].querySelectorAll(".cipher-container");
const viewCipherButton = cipherContainerElements[0].querySelector(".view-cipher-button");
const secondFillCipherButton =
cipherContainerElements[1].querySelector(".fill-cipher-button");
jest.spyOn(secondFillCipherButton as HTMLElement, "focus");
viewCipherButton.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowDown" }));
expect((secondFillCipherButton as HTMLElement).focus).toBeCalled();
});
it("allows the user to move keyboard focus to the previous cipher element on ArrowUp", () => {
const cipherContainerElements =
autofillOverlayList["overlayListContainer"].querySelectorAll(".cipher-container");
const viewCipherButton = cipherContainerElements[1].querySelector(".view-cipher-button");
const firstFillCipherButton =
cipherContainerElements[0].querySelector(".fill-cipher-button");
jest.spyOn(firstFillCipherButton as HTMLElement, "focus");
viewCipherButton.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowUp" }));
expect((firstFillCipherButton as HTMLElement).focus).toBeCalled();
});
it("ignores keyup events that do not include ArrowUp, ArrowDown, or ArrowRight", () => {
const viewCipherButton =
autofillOverlayList["overlayListContainer"].querySelector(".view-cipher-button");
jest.spyOn(viewCipherButton as HTMLElement, "focus");
viewCipherButton.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowRight" }));
expect((viewCipherButton as HTMLElement).focus).not.toBeCalled();
});
});
});
});
describe("global event listener handlers", () => {
it("does not post a `checkAutofillOverlayButtonFocused` message to the parent if the overlay is currently focused", () => {
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
postWindowMessage({ command: "checkAutofillOverlayListFocused" });
expect(globalThis.parent.postMessage).not.toHaveBeenCalled();
});
it("posts a `checkAutofillOverlayButtonFocused` message to the parent if the overlay is not currently focused", () => {
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(false);
postWindowMessage({ command: "checkAutofillOverlayListFocused" });
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "checkAutofillOverlayButtonFocused" },
"https://localhost/",
);
});
it("updates the list of ciphers", () => {
postWindowMessage(createInitAutofillOverlayListMessageMock());
const updateCiphersSpy = jest.spyOn(autofillOverlayList as any, "updateListItems");
postWindowMessage({ command: "updateOverlayListCiphers" });
expect(updateCiphersSpy).toHaveBeenCalled();
});
describe("directing user focus into the overlay list", () => {
it("sets ARIA attributes that define the list as a `dialog` to screen reader users", () => {
postWindowMessage(
createInitAutofillOverlayListMessageMock({
authStatus: AuthenticationStatus.Locked,
cipherList: [],
}),
);
const overlayContainerSetAttributeSpy = jest.spyOn(
autofillOverlayList["overlayListContainer"],
"setAttribute",
);
postWindowMessage({ command: "focusOverlayList" });
expect(overlayContainerSetAttributeSpy).toHaveBeenCalledWith("role", "dialog");
expect(overlayContainerSetAttributeSpy).toHaveBeenCalledWith("aria-modal", "true");
});
it("focuses the unlock button element if the user is not authenticated", () => {
postWindowMessage(
createInitAutofillOverlayListMessageMock({
authStatus: AuthenticationStatus.Locked,
cipherList: [],
}),
);
const unlockButton =
autofillOverlayList["overlayListContainer"].querySelector("#unlock-button");
jest.spyOn(unlockButton as HTMLElement, "focus");
postWindowMessage({ command: "focusOverlayList" });
expect((unlockButton as HTMLElement).focus).toBeCalled();
});
it("focuses the new item button element if the cipher list is empty", () => {
postWindowMessage(createInitAutofillOverlayListMessageMock({ ciphers: [] }));
const newItemButton =
autofillOverlayList["overlayListContainer"].querySelector("#new-item-button");
jest.spyOn(newItemButton as HTMLElement, "focus");
postWindowMessage({ command: "focusOverlayList" });
expect((newItemButton as HTMLElement).focus).toBeCalled();
});
it("focuses the first cipher button element if the cipher list is populated", () => {
postWindowMessage(createInitAutofillOverlayListMessageMock());
const firstCipherItem =
autofillOverlayList["overlayListContainer"].querySelector(".fill-cipher-button");
jest.spyOn(firstCipherItem as HTMLElement, "focus");
postWindowMessage({ command: "focusOverlayList" });
expect((firstCipherItem as HTMLElement).focus).toBeCalled();
});
});
});
describe("handleResizeObserver", () => {
beforeEach(() => {
postWindowMessage(createInitAutofillOverlayListMessageMock());
});
it("ignores resize entries whose target is not the overlay list", () => {
const entries = [
{
target: mock<HTMLElement>(),
contentRect: { height: 300 },
},
];
autofillOverlayList["handleResizeObserver"](entries as unknown as ResizeObserverEntry[]);
expect(globalThis.parent.postMessage).not.toHaveBeenCalled();
});
it("posts a message to update the overlay list height if the list container is resized", () => {
const entries = [
{
target: autofillOverlayList["overlayListContainer"],
contentRect: { height: 300 },
},
];
autofillOverlayList["handleResizeObserver"](entries as unknown as ResizeObserverEntry[]);
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "updateAutofillOverlayListHeight", styles: { height: "300px" } },
"https://localhost/",
);
});
});
});

View File

@@ -1,621 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import "@webcomponents/custom-elements";
import "lit/polyfill-support.js";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { EVENTS } from "@bitwarden/common/autofill/constants";
import { buildSvgDomElement } from "../../../../utils";
import { globeIcon, lockIcon, plusIcon, viewCipherIcon } from "../../../../utils/svg-icons";
import { OverlayCipherData } from "../../../background/abstractions/overlay.background.deprecated";
import {
InitAutofillOverlayListMessage,
OverlayListWindowMessageHandlers,
} from "../../abstractions/autofill-overlay-list.deprecated";
import AutofillOverlayPageElement from "../shared/autofill-overlay-page-element.deprecated";
class AutofillOverlayList extends AutofillOverlayPageElement {
private overlayListContainer: HTMLDivElement;
private resizeObserver: ResizeObserver;
private eventHandlersMemo: { [key: string]: EventListener } = {};
private ciphers: OverlayCipherData[] = [];
private ciphersList: HTMLUListElement;
private cipherListScrollIsDebounced = false;
private cipherListScrollDebounceTimeout: number | NodeJS.Timeout;
private currentCipherIndex = 0;
private readonly showCiphersPerPage = 6;
private readonly overlayListWindowMessageHandlers: OverlayListWindowMessageHandlers = {
initAutofillOverlayList: ({ message }) => this.initAutofillOverlayList(message),
checkAutofillOverlayListFocused: () => this.checkOverlayListFocused(),
updateOverlayListCiphers: ({ message }) => this.updateListItems(message.ciphers),
focusOverlayList: () => this.focusOverlayList(),
};
constructor() {
super();
this.setupOverlayListGlobalListeners();
}
/**
* Initializes the overlay list and updates the list items with the passed ciphers.
* If the auth status is not `Unlocked`, the locked overlay is built.
*
* @param translations - The translations to use for the overlay list.
* @param styleSheetUrl - The URL of the stylesheet to use for the overlay list.
* @param theme - The theme to use for the overlay list.
* @param authStatus - The current authentication status.
* @param ciphers - The ciphers to display in the overlay list.
*/
private async initAutofillOverlayList({
translations,
styleSheetUrl,
theme,
authStatus,
ciphers,
}: InitAutofillOverlayListMessage) {
const linkElement = this.initOverlayPage("list", styleSheetUrl, translations);
const themeClass = `theme_${theme}`;
globalThis.document.documentElement.classList.add(themeClass);
this.overlayListContainer = globalThis.document.createElement("div");
this.overlayListContainer.classList.add("overlay-list-container", themeClass);
this.resizeObserver.observe(this.overlayListContainer);
this.shadowDom.append(linkElement, this.overlayListContainer);
if (authStatus === AuthenticationStatus.Unlocked) {
this.updateListItems(ciphers);
return;
}
this.buildLockedOverlay();
}
/**
* Builds the locked overlay, which is displayed when the user is not authenticated.
* Facilitates the ability to unlock the extension from the overlay.
*/
private buildLockedOverlay() {
const lockedOverlay = globalThis.document.createElement("div");
lockedOverlay.id = "locked-overlay-description";
lockedOverlay.classList.add("locked-overlay", "overlay-list-message");
lockedOverlay.textContent = this.getTranslation("unlockYourAccount");
const unlockButtonElement = globalThis.document.createElement("button");
unlockButtonElement.id = "unlock-button";
unlockButtonElement.tabIndex = -1;
unlockButtonElement.classList.add("unlock-button", "overlay-list-button");
unlockButtonElement.textContent = this.getTranslation("unlockAccount");
unlockButtonElement.setAttribute(
"aria-label",
`${this.getTranslation("unlockAccount")}, ${this.getTranslation("opensInANewWindow")}`,
);
unlockButtonElement.prepend(buildSvgDomElement(lockIcon));
unlockButtonElement.addEventListener(EVENTS.CLICK, this.handleUnlockButtonClick);
const overlayListButtonContainer = globalThis.document.createElement("div");
overlayListButtonContainer.classList.add("overlay-list-button-container");
overlayListButtonContainer.appendChild(unlockButtonElement);
this.overlayListContainer.append(lockedOverlay, overlayListButtonContainer);
}
/**
* Handles the click event for the unlock button.
* Sends a message to the parent window to unlock the vault.
*/
private handleUnlockButtonClick = () => {
this.postMessageToParent({ command: "unlockVault" });
};
/**
* Updates the list items with the passed ciphers.
* If no ciphers are passed, the no results overlay is built.
*
* @param ciphers - The ciphers to display in the overlay list.
*/
private updateListItems(ciphers: OverlayCipherData[]) {
this.ciphers = ciphers;
this.currentCipherIndex = 0;
if (this.overlayListContainer) {
this.overlayListContainer.innerHTML = "";
}
if (!ciphers?.length) {
this.buildNoResultsOverlayList();
return;
}
this.ciphersList = globalThis.document.createElement("ul");
this.ciphersList.classList.add("overlay-actions-list");
this.ciphersList.setAttribute("role", "list");
globalThis.addEventListener(EVENTS.SCROLL, this.handleCiphersListScrollEvent);
this.loadPageOfCiphers();
this.overlayListContainer.appendChild(this.ciphersList);
}
/**
* Overlay view that is presented when no ciphers are found for a given page.
* Facilitates the ability to add a new vault item from the overlay.
*/
private buildNoResultsOverlayList() {
const noItemsMessage = globalThis.document.createElement("div");
noItemsMessage.classList.add("no-items", "overlay-list-message");
noItemsMessage.textContent = this.getTranslation("noItemsToShow");
const newItemButton = globalThis.document.createElement("button");
newItemButton.tabIndex = -1;
newItemButton.id = "new-item-button";
newItemButton.classList.add("add-new-item-button", "overlay-list-button");
newItemButton.textContent = this.getTranslation("newItem");
newItemButton.setAttribute(
"aria-label",
`${this.getTranslation("addNewVaultItem")}, ${this.getTranslation("opensInANewWindow")}`,
);
newItemButton.prepend(buildSvgDomElement(plusIcon));
newItemButton.addEventListener(EVENTS.CLICK, this.handeNewItemButtonClick);
const overlayListButtonContainer = globalThis.document.createElement("div");
overlayListButtonContainer.classList.add("overlay-list-button-container");
overlayListButtonContainer.appendChild(newItemButton);
this.overlayListContainer.append(noItemsMessage, overlayListButtonContainer);
}
/**
* Handles the click event for the new item button.
* Sends a message to the parent window to add a new vault item.
*/
private handeNewItemButtonClick = () => {
this.postMessageToParent({ command: "addNewVaultItem" });
};
/**
* Loads a page of ciphers into the overlay list container.
*/
private loadPageOfCiphers() {
const lastIndex = Math.min(
this.currentCipherIndex + this.showCiphersPerPage,
this.ciphers.length,
);
for (let cipherIndex = this.currentCipherIndex; cipherIndex < lastIndex; cipherIndex++) {
this.ciphersList.appendChild(this.buildOverlayActionsListItem(this.ciphers[cipherIndex]));
this.currentCipherIndex++;
}
if (this.currentCipherIndex >= this.ciphers.length) {
globalThis.removeEventListener(EVENTS.SCROLL, this.handleCiphersListScrollEvent);
}
}
/**
* Handles updating the list of ciphers when the
* user scrolls to the bottom of the list.
*/
private handleCiphersListScrollEvent = () => {
if (this.cipherListScrollIsDebounced) {
return;
}
this.cipherListScrollIsDebounced = true;
if (this.cipherListScrollDebounceTimeout) {
clearTimeout(this.cipherListScrollDebounceTimeout);
}
this.cipherListScrollDebounceTimeout = setTimeout(this.handleDebouncedScrollEvent, 300);
};
/**
* Debounced handler for updating the list of ciphers when the user scrolls to
* the bottom of the list. Triggers at most once every 300ms.
*/
private handleDebouncedScrollEvent = () => {
this.cipherListScrollIsDebounced = false;
if (globalThis.scrollY + globalThis.innerHeight >= this.ciphersList.clientHeight - 300) {
this.loadPageOfCiphers();
}
};
/**
* Builds the list item for a given cipher.
*
* @param cipher - The cipher to build the list item for.
*/
private buildOverlayActionsListItem(cipher: OverlayCipherData) {
const fillCipherElement = this.buildFillCipherElement(cipher);
const viewCipherElement = this.buildViewCipherElement(cipher);
const cipherContainerElement = globalThis.document.createElement("div");
cipherContainerElement.classList.add("cipher-container");
cipherContainerElement.append(fillCipherElement, viewCipherElement);
const overlayActionsListItem = globalThis.document.createElement("li");
overlayActionsListItem.setAttribute("role", "listitem");
overlayActionsListItem.classList.add("overlay-actions-list-item");
overlayActionsListItem.appendChild(cipherContainerElement);
return overlayActionsListItem;
}
/**
* Builds the fill cipher button for a given cipher.
* Wraps the cipher icon and details.
*
* @param cipher - The cipher to build the fill cipher button for.
*/
private buildFillCipherElement(cipher: OverlayCipherData) {
const cipherIcon = this.buildCipherIconElement(cipher);
const cipherDetailsElement = this.buildCipherDetailsElement(cipher);
const fillCipherElement = globalThis.document.createElement("button");
fillCipherElement.tabIndex = -1;
fillCipherElement.classList.add("fill-cipher-button");
fillCipherElement.setAttribute(
"aria-label",
`${this.getTranslation("fillCredentialsFor")} ${cipher.name}`,
);
fillCipherElement.setAttribute(
"aria-description",
`${this.getTranslation("partialUsername")}, ${cipher.login.username}`,
);
fillCipherElement.append(cipherIcon, cipherDetailsElement);
fillCipherElement.addEventListener(EVENTS.CLICK, this.handleFillCipherClickEvent(cipher));
fillCipherElement.addEventListener(EVENTS.KEYUP, this.handleFillCipherKeyUpEvent);
return fillCipherElement;
}
/**
* Handles the click event for the fill cipher button.
* Sends a message to the parent window to fill the selected cipher.
*
* @param cipher - The cipher to fill.
*/
private handleFillCipherClickEvent = (cipher: OverlayCipherData) => {
return this.useEventHandlersMemo(
() =>
this.postMessageToParent({
command: "fillSelectedListItem",
overlayCipherId: cipher.id,
}),
`${cipher.id}-fill-cipher-button-click-handler`,
);
};
/**
* Handles the keyup event for the fill cipher button. Facilitates
* selecting the next/previous cipher item on ArrowDown/ArrowUp. Also
* facilitates moving keyboard focus to the view cipher button on ArrowRight.
*
* @param event - The keyup event.
*/
private handleFillCipherKeyUpEvent = (event: KeyboardEvent) => {
const listenedForKeys = new Set(["ArrowDown", "ArrowUp", "ArrowRight"]);
if (!listenedForKeys.has(event.code) || !(event.target instanceof Element)) {
return;
}
event.preventDefault();
const currentListItem = event.target.closest(".overlay-actions-list-item") as HTMLElement;
if (event.code === "ArrowDown") {
this.focusNextListItem(currentListItem);
return;
}
if (event.code === "ArrowUp") {
this.focusPreviousListItem(currentListItem);
return;
}
this.focusViewCipherButton(currentListItem, event.target as HTMLElement);
};
/**
* Builds the button that facilitates viewing a cipher in the vault.
*
* @param cipher - The cipher to view.
*/
private buildViewCipherElement(cipher: OverlayCipherData) {
const viewCipherElement = globalThis.document.createElement("button");
viewCipherElement.tabIndex = -1;
viewCipherElement.classList.add("view-cipher-button");
viewCipherElement.setAttribute(
"aria-label",
`${this.getTranslation("view")} ${cipher.name}, ${this.getTranslation("opensInANewWindow")}`,
);
viewCipherElement.append(buildSvgDomElement(viewCipherIcon));
viewCipherElement.addEventListener(EVENTS.CLICK, this.handleViewCipherClickEvent(cipher));
viewCipherElement.addEventListener(EVENTS.KEYUP, this.handleViewCipherKeyUpEvent);
return viewCipherElement;
}
/**
* Handles the click event for the view cipher button. Sends a
* message to the parent window to view the selected cipher.
*
* @param cipher - The cipher to view.
*/
private handleViewCipherClickEvent = (cipher: OverlayCipherData) => {
return this.useEventHandlersMemo(
() => this.postMessageToParent({ command: "viewSelectedCipher", overlayCipherId: cipher.id }),
`${cipher.id}-view-cipher-button-click-handler`,
);
};
/**
* Handles the keyup event for the view cipher button. Facilitates
* selecting the next/previous cipher item on ArrowDown/ArrowUp.
* Also facilitates moving keyboard focus to the current fill
* cipher button on ArrowLeft.
*
* @param event - The keyup event.
*/
private handleViewCipherKeyUpEvent = (event: KeyboardEvent) => {
const listenedForKeys = new Set(["ArrowDown", "ArrowUp", "ArrowLeft"]);
if (!listenedForKeys.has(event.code) || !(event.target instanceof Element)) {
return;
}
event.preventDefault();
const currentListItem = event.target.closest(".overlay-actions-list-item") as HTMLElement;
const cipherContainer = currentListItem.querySelector(".cipher-container") as HTMLElement;
cipherContainer?.classList.remove("remove-outline");
if (event.code === "ArrowDown") {
this.focusNextListItem(currentListItem);
return;
}
if (event.code === "ArrowUp") {
this.focusPreviousListItem(currentListItem);
return;
}
const previousSibling = event.target.previousElementSibling as HTMLElement;
previousSibling?.focus();
};
/**
* Builds the icon for a given cipher. Prioritizes the favicon from a given cipher url
* and the default icon element within the extension. If neither are available, the
* globe icon is used.
*
* @param cipher - The cipher to build the icon for.
*/
private buildCipherIconElement(cipher: OverlayCipherData) {
const cipherIcon = globalThis.document.createElement("span");
cipherIcon.classList.add("cipher-icon");
cipherIcon.setAttribute("aria-hidden", "true");
if (cipher.icon?.image) {
try {
const url = new URL(cipher.icon.image);
cipherIcon.style.backgroundImage = `url(${url.href})`;
const dummyImageElement = globalThis.document.createElement("img");
dummyImageElement.src = url.href;
dummyImageElement.addEventListener("error", () => {
cipherIcon.style.backgroundImage = "";
cipherIcon.classList.add("cipher-icon");
cipherIcon.append(buildSvgDomElement(globeIcon));
});
dummyImageElement.remove();
return cipherIcon;
} catch {
// Silently default to the globe icon element if the image URL is invalid
}
}
if (cipher.icon?.icon) {
const iconClasses = cipher.icon.icon.split(" ");
cipherIcon.classList.add("cipher-icon", "bwi", ...iconClasses);
return cipherIcon;
}
cipherIcon.append(buildSvgDomElement(globeIcon));
return cipherIcon;
}
/**
* Builds the details for a given cipher. Includes the cipher name and username login.
*
* @param cipher - The cipher to build the details for.
*/
private buildCipherDetailsElement(cipher: OverlayCipherData) {
const cipherNameElement = this.buildCipherNameElement(cipher);
const cipherUserLoginElement = this.buildCipherUserLoginElement(cipher);
const cipherDetailsElement = globalThis.document.createElement("span");
cipherDetailsElement.classList.add("cipher-details");
if (cipherNameElement) {
cipherDetailsElement.appendChild(cipherNameElement);
}
if (cipherUserLoginElement) {
cipherDetailsElement.appendChild(cipherUserLoginElement);
}
return cipherDetailsElement;
}
/**
* Builds the name element for a given cipher.
*
* @param cipher - The cipher to build the name element for.
*/
private buildCipherNameElement(cipher: OverlayCipherData): HTMLSpanElement | null {
if (!cipher.name) {
return null;
}
const cipherNameElement = globalThis.document.createElement("span");
cipherNameElement.classList.add("cipher-name");
cipherNameElement.textContent = cipher.name;
cipherNameElement.setAttribute("title", cipher.name);
return cipherNameElement;
}
/**
* Builds the username login element for a given cipher.
*
* @param cipher - The cipher to build the username login element for.
*/
private buildCipherUserLoginElement(cipher: OverlayCipherData): HTMLSpanElement | null {
if (!cipher.login?.username) {
return null;
}
const cipherUserLoginElement = globalThis.document.createElement("span");
cipherUserLoginElement.classList.add("cipher-user-login");
cipherUserLoginElement.textContent = cipher.login.username;
cipherUserLoginElement.setAttribute("title", cipher.login.username);
return cipherUserLoginElement;
}
/**
* Validates whether the overlay list iframe is currently focused.
* If not focused, will check if the button element is focused.
*/
private checkOverlayListFocused() {
if (globalThis.document.hasFocus()) {
return;
}
this.postMessageToParent({ command: "checkAutofillOverlayButtonFocused" });
}
/**
* Focuses the overlay list iframe. The element that receives focus is
* determined by the presence of the unlock button, new item button, or
* the first cipher button.
*/
private focusOverlayList() {
this.overlayListContainer.setAttribute("role", "dialog");
this.overlayListContainer.setAttribute("aria-modal", "true");
const unlockButtonElement = this.overlayListContainer.querySelector(
"#unlock-button",
) as HTMLElement;
if (unlockButtonElement) {
unlockButtonElement.focus();
return;
}
const newItemButtonElement = this.overlayListContainer.querySelector(
"#new-item-button",
) as HTMLElement;
if (newItemButtonElement) {
newItemButtonElement.focus();
return;
}
const firstCipherElement = this.overlayListContainer.querySelector(
".fill-cipher-button",
) as HTMLElement;
firstCipherElement?.focus();
}
/**
* Sets up the global listeners for the overlay list iframe.
*/
private setupOverlayListGlobalListeners() {
this.setupGlobalListeners(this.overlayListWindowMessageHandlers);
this.resizeObserver = new ResizeObserver(this.handleResizeObserver);
}
/**
* Handles the resize observer event. Facilitates updating the height of the
* overlay list iframe when the height of the list changes.
*
* @param entries - The resize observer entries.
*/
private handleResizeObserver = (entries: ResizeObserverEntry[]) => {
for (let entryIndex = 0; entryIndex < entries.length; entryIndex++) {
const entry = entries[entryIndex];
if (entry.target !== this.overlayListContainer) {
continue;
}
const { height } = entry.contentRect;
this.postMessageToParent({
command: "updateAutofillOverlayListHeight",
styles: { height: `${height}px` },
});
break;
}
};
/**
* Establishes a memoized event handler for a given event.
*
* @param eventHandler - The event handler to memoize.
* @param memoIndex - The memo index to use for the event handler.
*/
private useEventHandlersMemo = (eventHandler: EventListener, memoIndex: string) => {
return this.eventHandlersMemo[memoIndex] || (this.eventHandlersMemo[memoIndex] = eventHandler);
};
/**
* Focuses the next list item in the overlay list. If the current list item is the last
* item in the list, the first item is focused.
*
* @param currentListItem - The current list item.
*/
private focusNextListItem(currentListItem: HTMLElement) {
const nextListItem = currentListItem.nextSibling as HTMLElement;
const nextSibling = nextListItem?.querySelector(".fill-cipher-button") as HTMLElement;
if (nextSibling) {
nextSibling.focus();
return;
}
const firstListItem = currentListItem.parentElement?.firstChild as HTMLElement;
const firstSibling = firstListItem?.querySelector(".fill-cipher-button") as HTMLElement;
firstSibling?.focus();
}
/**
* Focuses the previous list item in the overlay list. If the current list item is the first
* item in the list, the last item is focused.
*
* @param currentListItem - The current list item.
*/
private focusPreviousListItem(currentListItem: HTMLElement) {
const previousListItem = currentListItem.previousSibling as HTMLElement;
const previousSibling = previousListItem?.querySelector(".fill-cipher-button") as HTMLElement;
if (previousSibling) {
previousSibling.focus();
return;
}
const lastListItem = currentListItem.parentElement?.lastChild as HTMLElement;
const lastSibling = lastListItem?.querySelector(".fill-cipher-button") as HTMLElement;
lastSibling?.focus();
}
/**
* Focuses the view cipher button relative to the current fill cipher button.
*
* @param currentListItem - The current list item.
* @param currentButtonElement - The current button element.
*/
private focusViewCipherButton(currentListItem: HTMLElement, currentButtonElement: HTMLElement) {
const cipherContainer = currentListItem.querySelector(".cipher-container") as HTMLElement;
cipherContainer.classList.add("remove-outline");
const nextSibling = currentButtonElement.nextElementSibling as HTMLElement;
nextSibling?.focus();
}
}
export default AutofillOverlayList;

View File

@@ -1,11 +0,0 @@
import { AutofillOverlayElement } from "../../../../enums/autofill-overlay.enum";
import AutofillOverlayList from "./autofill-overlay-list.deprecated";
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("./legacy-list.scss");
(function () {
globalThis.customElements.define(AutofillOverlayElement.List, AutofillOverlayList);
})();

View File

@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<title>Bitwarden vault</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="normal" />
</head>
<body>
<autofill-inline-menu-list></autofill-inline-menu-list>
</body>
</html>

View File

@@ -1,292 +0,0 @@
@import "../../../../shared/styles/webfonts";
@import "../../../../shared/styles/variables";
@import "../../../../../../../../libs/angular/src/scss/icons";
* {
box-sizing: border-box;
}
html {
font-size: 10px;
}
body {
width: 100%;
padding: 0;
margin: 0;
@include themify($themes) {
color: themed("textColor");
background-color: themed("backgroundColor");
}
}
.overlay-list-message {
font-family: $font-family-sans-serif;
font-weight: 400;
font-size: 1.4rem;
line-height: 1.5;
width: 100%;
padding: 0.8rem;
@include themify($themes) {
color: themed("textColor");
}
&.no-items {
font-size: 1.6rem;
}
}
.overlay-list-button-container {
width: 100%;
padding: 0.2rem;
background: transparent;
transition: background-color 0.2s ease-in-out;
border-top-width: 0.1rem;
border-top-style: solid;
@include themify($themes) {
border-top-color: themed("borderColor");
}
&:hover {
@include themify($themes) {
background: themed("backgroundOffsetColor");
}
}
}
.overlay-list-button {
display: flex;
align-content: center;
justify-content: flex-start;
width: 100%;
font-family: $font-family-sans-serif;
font-size: 1.6rem;
font-weight: 700;
text-align: left;
background: transparent;
border: none;
padding: 0.7rem;
margin: 0;
cursor: pointer;
border-radius: 0.4rem;
@include themify($themes) {
color: themed("primaryColor");
}
&:focus:focus-visible {
outline-width: 0.2rem;
outline-style: solid;
@include themify($themes) {
outline-color: themed("focusOutlineColor");
}
}
svg {
position: relative;
margin-left: 0.4rem;
margin-right: 0.8rem;
path {
@include themify($themes) {
fill: themed("primaryColor") !important;
}
}
}
}
.unlock-button {
svg {
top: 0.2rem;
width: 1.6rem;
height: 1.7rem;
}
}
.add-new-item-button {
svg {
top: 0.2rem;
width: 1.7rem;
height: 1.7rem;
}
}
.overlay-actions-list {
padding: 0;
margin: 0;
}
.overlay-actions-list-item {
transition: background-color 0.2s ease-in-out;
list-style: none;
padding: 0.2rem;
&:not(:last-child) {
border-bottom-width: 0.1rem;
border-bottom-style: solid;
@include themify($themes) {
border-bottom-color: themed("borderColor");
}
}
&:hover {
@include themify($themes) {
background: themed("backgroundOffsetColor");
}
}
.cipher-container {
display: flex;
align-content: flex-start;
align-items: center;
justify-content: flex-start;
padding: 0.7rem 0.3rem 0.7rem 0.7rem;
border-radius: 0.4rem;
&:focus-within:not(.remove-outline) {
outline-width: 0.2rem;
outline-style: solid;
@include themify($themes) {
outline-color: themed("focusOutlineColor");
}
}
}
.fill-cipher-button,
.view-cipher-button {
padding: 0;
margin: 0;
line-height: 0;
background-color: transparent;
border: none;
cursor: pointer;
}
.fill-cipher-button {
display: flex;
align-items: center;
align-content: center;
justify-content: flex-start;
width: calc(100% - 4rem);
outline: none;
}
.view-cipher-button {
flex-shrink: 0;
width: 4rem;
height: 4rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.4rem;
&:focus:focus-visible {
outline-width: 0.2rem;
outline-style: solid;
@include themify($themes) {
outline-color: themed("focusOutlineColor");
}
}
svg {
path {
@include themify($themes) {
fill: themed("primaryColor") !important;
}
}
}
}
.cipher-icon {
display: flex;
align-content: center;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 3.2rem;
height: 3.2rem;
margin: 0 1rem 0 0;
line-height: 0;
background-size: 2.6rem;
background-position: center;
background-repeat: no-repeat;
@include themify($themes) {
color: themed("mutedTextColor");
}
svg {
width: 100%;
height: auto;
flex-shrink: 0;
path {
@include themify($themes) {
fill: themed("primaryColor") !important;
}
}
}
&.bwi {
font-size: 2.6rem;
&:not(.cipher-icon) {
@include themify($themes) {
color: themed("primaryColor");
}
svg {
path {
@include themify($themes) {
fill: themed("primaryColor") !important;
}
}
}
}
}
}
.cipher-details {
display: block;
width: 100%;
text-overflow: ellipsis;
overflow: hidden;
text-align: left;
}
.cipher-name,
.cipher-user-login {
display: block;
width: 100%;
line-height: 1.5;
font-family: $font-family-sans-serif;
font-weight: 400;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
cursor: pointer;
}
.cipher-name {
font-size: 1.6rem;
@include themify($themes) {
color: themed("textColor");
}
}
.cipher-user-login {
font-size: 1.4rem;
@include themify($themes) {
color: themed("mutedTextColor");
}
}
}

View File

@@ -1,222 +0,0 @@
import { mock } from "jest-mock-extended";
import { OverlayButtonWindowMessageHandlers } from "../../abstractions/autofill-overlay-button.deprecated";
import AutofillOverlayPageElementDeprecated from "./autofill-overlay-page-element.deprecated";
describe("AutofillOverlayPageElement", () => {
globalThis.customElements.define(
"autofill-overlay-page-element",
AutofillOverlayPageElementDeprecated,
);
let autofillOverlayPageElement: AutofillOverlayPageElementDeprecated;
const translations = {
locale: "en",
buttonPageTitle: "buttonPageTitle",
listPageTitle: "listPageTitle",
};
beforeEach(() => {
jest.spyOn(globalThis.parent, "postMessage");
jest.spyOn(globalThis, "addEventListener");
jest.spyOn(globalThis.document, "addEventListener");
document.body.innerHTML = "<autofill-overlay-page-element></autofill-overlay-page-element>";
autofillOverlayPageElement = document.querySelector("autofill-overlay-page-element");
});
afterEach(() => {
jest.clearAllMocks();
});
describe("initOverlayPage", () => {
beforeEach(() => {
jest.spyOn(globalThis.document.documentElement, "setAttribute");
jest.spyOn(globalThis.document, "createElement");
});
it("initializes the button overlay page", () => {
const linkElement = autofillOverlayPageElement["initOverlayPage"](
"button",
"https://jest-testing-website.com",
translations,
);
expect(globalThis.document.documentElement.setAttribute).toHaveBeenCalledWith(
"lang",
translations.locale,
);
expect(globalThis.document.head.title).toEqual(translations.buttonPageTitle);
expect(globalThis.document.createElement).toHaveBeenCalledWith("link");
expect(linkElement.getAttribute("rel")).toEqual("stylesheet");
expect(linkElement.getAttribute("href")).toEqual("https://jest-testing-website.com");
});
});
describe("postMessageToParent", () => {
it("skips posting a message to the parent if the message origin in not set", () => {
autofillOverlayPageElement["postMessageToParent"]({ command: "test" });
expect(globalThis.parent.postMessage).not.toHaveBeenCalled();
});
it("posts a message to the parent", () => {
autofillOverlayPageElement["messageOrigin"] = "https://jest-testing-website.com";
autofillOverlayPageElement["postMessageToParent"]({ command: "test" });
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "test" },
"https://jest-testing-website.com",
);
});
});
describe("getTranslation", () => {
it("returns an empty value if the translation doesn't exist in the translations object", () => {
autofillOverlayPageElement["translations"] = translations;
expect(autofillOverlayPageElement["getTranslation"]("test")).toEqual("");
});
});
describe("global event listeners", () => {
it("sets up global event listeners", () => {
const handleWindowMessageSpy = jest.spyOn(
autofillOverlayPageElement as any,
"handleWindowMessage",
);
const handleWindowBlurEventSpy = jest.spyOn(
autofillOverlayPageElement as any,
"handleWindowBlurEvent",
);
const handleDocumentKeyDownEventSpy = jest.spyOn(
autofillOverlayPageElement as any,
"handleDocumentKeyDownEvent",
);
autofillOverlayPageElement["setupGlobalListeners"](
mock<OverlayButtonWindowMessageHandlers>(),
);
expect(globalThis.addEventListener).toHaveBeenCalledWith("message", handleWindowMessageSpy);
expect(globalThis.addEventListener).toHaveBeenCalledWith("blur", handleWindowBlurEventSpy);
expect(globalThis.document.addEventListener).toHaveBeenCalledWith(
"keydown",
handleDocumentKeyDownEventSpy,
);
});
it("sets the message origin when handling the first passed window message", () => {
const initAutofillOverlayButtonSpy = jest.fn();
autofillOverlayPageElement["setupGlobalListeners"](
mock<OverlayButtonWindowMessageHandlers>({
initAutofillOverlayButton: initAutofillOverlayButtonSpy,
}),
);
globalThis.dispatchEvent(
new MessageEvent("message", {
data: { command: "initAutofillOverlayButton" },
origin: "https://jest-testing-website.com",
}),
);
expect(autofillOverlayPageElement["messageOrigin"]).toEqual(
"https://jest-testing-website.com",
);
});
it("handles window messages that are part of the passed windowMessageHandlers object", () => {
const initAutofillOverlayButtonSpy = jest.fn();
autofillOverlayPageElement["setupGlobalListeners"](
mock<OverlayButtonWindowMessageHandlers>({
initAutofillOverlayButton: initAutofillOverlayButtonSpy,
}),
);
const data = { command: "initAutofillOverlayButton" };
globalThis.dispatchEvent(new MessageEvent("message", { data }));
expect(initAutofillOverlayButtonSpy).toHaveBeenCalledWith({ message: data });
});
it("skips attempting to handle window messages that are not part of the passed windowMessageHandlers object", () => {
const initAutofillOverlayButtonSpy = jest.fn();
autofillOverlayPageElement["setupGlobalListeners"](
mock<OverlayButtonWindowMessageHandlers>({
initAutofillOverlayButton: initAutofillOverlayButtonSpy,
}),
);
globalThis.dispatchEvent(new MessageEvent("message", { data: { command: "test" } }));
expect(initAutofillOverlayButtonSpy).not.toHaveBeenCalled();
});
it("posts a message to the parent when the window is blurred", () => {
autofillOverlayPageElement["messageOrigin"] = "https://jest-testing-website.com";
autofillOverlayPageElement["setupGlobalListeners"](
mock<OverlayButtonWindowMessageHandlers>(),
);
globalThis.dispatchEvent(new Event("blur"));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "overlayPageBlurred" },
"https://jest-testing-website.com",
);
});
it("skips redirecting keyboard focus when a KeyDown event triggers and the key is not a `Tab` or `Escape` key", () => {
autofillOverlayPageElement["setupGlobalListeners"](
mock<OverlayButtonWindowMessageHandlers>(),
);
globalThis.document.dispatchEvent(new KeyboardEvent("keydown", { code: "test" }));
expect(globalThis.parent.postMessage).not.toHaveBeenCalled();
});
it("redirects the overlay focus out to the previous element on KeyDown of the `Tab+Shift` keys", () => {
autofillOverlayPageElement["messageOrigin"] = "https://jest-testing-website.com";
autofillOverlayPageElement["setupGlobalListeners"](
mock<OverlayButtonWindowMessageHandlers>(),
);
globalThis.document.dispatchEvent(
new KeyboardEvent("keydown", { code: "Tab", shiftKey: true }),
);
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "redirectOverlayFocusOut", direction: "previous" },
"https://jest-testing-website.com",
);
});
it("redirects the overlay focus out to the next element on KeyDown of the `Tab` key", () => {
autofillOverlayPageElement["messageOrigin"] = "https://jest-testing-website.com";
autofillOverlayPageElement["setupGlobalListeners"](
mock<OverlayButtonWindowMessageHandlers>(),
);
globalThis.document.dispatchEvent(new KeyboardEvent("keydown", { code: "Tab" }));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "redirectOverlayFocusOut", direction: "next" },
"https://jest-testing-website.com",
);
});
it("redirects the overlay focus out to the current element on KeyDown of the `Escape` key", () => {
autofillOverlayPageElement["messageOrigin"] = "https://jest-testing-website.com";
autofillOverlayPageElement["setupGlobalListeners"](
mock<OverlayButtonWindowMessageHandlers>(),
);
globalThis.document.dispatchEvent(new KeyboardEvent("keydown", { code: "Escape" }));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "redirectOverlayFocusOut", direction: "current" },
"https://jest-testing-website.com",
);
});
});
});

View File

@@ -1,157 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { EVENTS } from "@bitwarden/common/autofill/constants";
import { RedirectFocusDirection } from "../../../../enums/autofill-overlay.enum";
import {
AutofillOverlayPageElementWindowMessage,
WindowMessageHandlers,
} from "../../abstractions/autofill-overlay-page-element.deprecated";
class AutofillOverlayPageElement extends HTMLElement {
protected shadowDom: ShadowRoot;
protected messageOrigin: string;
protected translations: Record<string, string>;
protected windowMessageHandlers: WindowMessageHandlers;
constructor() {
super();
this.shadowDom = this.attachShadow({ mode: "closed" });
}
/**
* Initializes the overlay page element. Facilitates ensuring that the page
* is set up with the expected styles and translations.
*
* @param elementName - The name of the element, e.g. "button" or "list"
* @param styleSheetUrl - The URL of the stylesheet to apply to the page
* @param translations - The translations to apply to the page
*/
protected initOverlayPage(
elementName: "button" | "list",
styleSheetUrl: string,
translations: Record<string, string>,
): HTMLLinkElement {
this.translations = translations;
globalThis.document.documentElement.setAttribute("lang", this.getTranslation("locale"));
globalThis.document.head.title = this.getTranslation(`${elementName}PageTitle`);
this.shadowDom.innerHTML = "";
const linkElement = globalThis.document.createElement("link");
linkElement.setAttribute("rel", "stylesheet");
linkElement.setAttribute("href", styleSheetUrl);
return linkElement;
}
/**
* Posts a window message to the parent window.
*
* @param message - The message to post
*/
protected postMessageToParent(message: AutofillOverlayPageElementWindowMessage) {
if (!this.messageOrigin) {
return;
}
globalThis.parent.postMessage(message, this.messageOrigin);
}
/**
* Gets a translation from the translations object.
*
* @param key
* @protected
*/
protected getTranslation(key: string): string {
return this.translations[key] || "";
}
/**
* Sets up global listeners for the window message, window blur, and
* document keydown events.
*
* @param windowMessageHandlers - The window message handlers to use
*/
protected setupGlobalListeners(windowMessageHandlers: WindowMessageHandlers) {
this.windowMessageHandlers = windowMessageHandlers;
globalThis.addEventListener(EVENTS.MESSAGE, this.handleWindowMessage);
globalThis.addEventListener(EVENTS.BLUR, this.handleWindowBlurEvent);
globalThis.document.addEventListener(EVENTS.KEYDOWN, this.handleDocumentKeyDownEvent);
}
/**
* Handles window messages from the parent window.
*
* @param event - The window message event
*/
private handleWindowMessage = (event: MessageEvent) => {
if (!this.windowMessageHandlers) {
return;
}
if (!this.messageOrigin) {
this.messageOrigin = event.origin;
}
if (event.origin !== this.messageOrigin) {
return;
}
const message = event?.data;
const handler = this.windowMessageHandlers[message?.command];
if (!handler) {
return;
}
handler({ message });
};
/**
* Handles the window blur event.
*/
private handleWindowBlurEvent = () => {
this.postMessageToParent({ command: "overlayPageBlurred" });
};
/**
* Handles the document keydown event. Facilitates redirecting the
* user focus in the right direction out of the overlay. Also facilitates
* closing the overlay when the user presses the Escape key.
*
* @param event - The document keydown event
*/
private handleDocumentKeyDownEvent = (event: KeyboardEvent) => {
const listenedForKeys = new Set(["Tab", "Escape"]);
if (!listenedForKeys.has(event.code)) {
return;
}
event.preventDefault();
event.stopPropagation();
if (event.code === "Tab") {
this.redirectOverlayFocusOutMessage(
event.shiftKey ? RedirectFocusDirection.Previous : RedirectFocusDirection.Next,
);
return;
}
this.redirectOverlayFocusOutMessage(RedirectFocusDirection.Current);
};
/**
* Redirects the overlay focus out to the previous element on KeyDown of the `Tab+Shift` keys.
* Redirects the overlay focus out to the next element on KeyDown of the `Tab` key.
* Redirects the overlay focus out to the current element on KeyDown of the `Escape` key.
*
* @param direction - The direction to redirect the focus out
*/
private redirectOverlayFocusOutMessage(direction: string) {
this.postMessageToParent({ command: "redirectOverlayFocusOut", direction });
}
}
export default AutofillOverlayPageElement;

View File

@@ -1,37 +0,0 @@
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import AutofillField from "../../../models/autofill-field";
import AutofillPageDetails from "../../../models/autofill-page-details";
import { AutofillOverlayContentService } from "../../../services/abstractions/autofill-overlay-content.service";
import { ElementWithOpId, FormFieldElement } from "../../../types";
type OpenAutofillOverlayOptions = {
isFocusingFieldElement?: boolean;
isOpeningFullOverlay?: boolean;
authStatus?: AuthenticationStatus;
};
interface LegacyAutofillOverlayContentService extends AutofillOverlayContentService {
isFieldCurrentlyFocused: boolean;
isCurrentlyFilling: boolean;
isOverlayCiphersPopulated: boolean;
pageDetailsUpdateRequired: boolean;
autofillOverlayVisibility: number;
init(): void;
setupAutofillOverlayListenerOnField(
autofillFieldElement: ElementWithOpId<FormFieldElement>,
autofillFieldData: AutofillField,
pageDetails: AutofillPageDetails,
): Promise<void>;
openAutofillOverlay(options: OpenAutofillOverlayOptions): void;
removeAutofillOverlay(): void;
removeAutofillOverlayButton(): void;
removeAutofillOverlayList(): void;
addNewVaultItem(): void;
redirectOverlayFocusOut(direction: "previous" | "next"): void;
focusMostRecentOverlayField(): void;
blurMostRecentOverlayField(): void;
destroy(): void;
}
export { OpenAutofillOverlayOptions, LegacyAutofillOverlayContentService };

View File

@@ -4,47 +4,14 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList creates the build sav
<div
class="inline-menu-list-container theme_light"
>
<div
class="save-login inline-menu-list-message"
/>
<div
class="inline-menu-list-button-container"
>
<button
aria-label=""
class="add-new-item-button inline-menu-list-button inline-menu-list-action"
id="new-item-button"
aria-label=", opensInANewWindow"
class="save-login inline-menu-list-button inline-menu-list-action"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="17"
width="17"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
clip-rule="evenodd"
d="M9.607 7.15h5.35c.322 0 .627.133.847.362a1.213 1.213 0 0 1 .002 1.68c-.221.23-.527.363-.85.363H9.607v5.652c0 .312-.12.613-.336.839a1.176 1.176 0 0 1-1.696.003 1.21 1.21 0 0 1-.34-.842V9.555H1.888a1.173 1.173 0 0 1-.847-.361A1.193 1.193 0 0 1 .7 8.352a1.219 1.219 0 0 1 .336-.838 1.175 1.175 0 0 1 .85-.364h5.349V1.635c0-.31.118-.611.336-.84A1.176 1.176 0 0 1 9.268.795c.222.228.34.533.34.841V7.15Z"
fill="#175DDC"
fill-rule="evenodd"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M.421.421h16v16h-16z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
/>
</div>
</div>
`;

View File

@@ -1089,12 +1089,12 @@ describe("AutofillInlineMenuList", () => {
});
describe("displaying the save login view", () => {
let buildSaveLoginInlineMenuListSpy: jest.SpyInstance;
let buildSaveLoginInlineMenuSpy: jest.SpyInstance;
beforeEach(() => {
buildSaveLoginInlineMenuListSpy = jest.spyOn(
buildSaveLoginInlineMenuSpy = jest.spyOn(
autofillInlineMenuList as any,
"buildSaveLoginInlineMenuList",
"buildSaveLoginInlineMenu",
);
});
@@ -1108,7 +1108,7 @@ describe("AutofillInlineMenuList", () => {
postWindowMessage({ command: "showSaveLoginInlineMenuList" });
expect(buildSaveLoginInlineMenuListSpy).not.toHaveBeenCalled();
expect(buildSaveLoginInlineMenuSpy).not.toHaveBeenCalled();
});
it("builds the save login item view", async () => {
@@ -1117,7 +1117,7 @@ describe("AutofillInlineMenuList", () => {
postWindowMessage({ command: "showSaveLoginInlineMenuList" });
expect(buildSaveLoginInlineMenuListSpy).toHaveBeenCalled();
expect(buildSaveLoginInlineMenuSpy).toHaveBeenCalled();
});
});

View File

@@ -3,6 +3,8 @@
import "@webcomponents/custom-elements";
import "lit/polyfill-support.js";
import { FocusableElement } from "tabbable";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { EVENTS, UPDATE_PASSKEYS_HEADINGS_ON_SCROLL } from "@bitwarden/common/autofill/constants";
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
@@ -117,7 +119,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
}
if (showSaveLoginMenu) {
this.buildSaveLoginInlineMenuList();
this.buildSaveLoginInlineMenu();
return;
}
@@ -165,24 +167,52 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
/**
* Builds the inline menu list as a prompt that asks the user if they'd like to save the login data.
*/
private buildSaveLoginInlineMenuList() {
const saveLoginMessage = globalThis.document.createElement("div");
saveLoginMessage.classList.add("save-login", "inline-menu-list-message");
saveLoginMessage.textContent = this.getTranslation("saveLoginToBitwarden");
private buildSaveLoginInlineMenu() {
const saveLoginButton = globalThis.document.createElement("button");
saveLoginButton.classList.add(
"save-login",
"inline-menu-list-button",
"inline-menu-list-action",
);
saveLoginButton.tabIndex = -1;
saveLoginButton.setAttribute(
"aria-label",
`${this.getTranslation("saveToBitwarden")}, ${this.getTranslation("opensInANewWindow")}`,
);
saveLoginButton.textContent = this.getTranslation("saveToBitwarden");
saveLoginButton.addEventListener(EVENTS.CLICK, this.handleNewLoginVaultItemAction);
saveLoginButton.addEventListener(EVENTS.KEYUP, this.handleSaveLoginInlineMenuKeyUp);
const inlineMenuListButtonContainer = this.buildButtonContainer(saveLoginButton);
const newItemButton = this.buildNewItemButton(true);
this.showInlineMenuAccountCreation = true;
this.inlineMenuListContainer.append(saveLoginMessage, newItemButton);
this.inlineMenuListContainer.append(inlineMenuListButtonContainer);
}
private handleSaveLoginInlineMenuKeyUp = (event: KeyboardEvent) => {
const listenedForKeys = new Set(["ArrowDown"]);
if (!listenedForKeys.has(event.code) || !(event.target instanceof Element)) {
return;
}
event.preventDefault();
if (event.code === "ArrowDown") {
(event.target as FocusableElement).focus();
return;
}
};
/**
* Handles the show save login inline menu list message that is triggered from the background script.
*/
private handleShowSaveLoginInlineMenuList() {
if (this.authStatus === AuthenticationStatus.Unlocked) {
this.resetInlineMenuContainer();
this.buildSaveLoginInlineMenuList();
this.buildSaveLoginInlineMenu();
}
}
@@ -521,7 +551,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
this.newItemButtonElement.textContent = this.getNewItemButtonText(showLogin);
this.newItemButtonElement.setAttribute("aria-label", this.getNewItemAriaLabel(showLogin));
this.newItemButtonElement.prepend(buildSvgDomElement(plusIcon));
this.newItemButtonElement.addEventListener(EVENTS.CLICK, this.handeNewItemButtonClick);
this.newItemButtonElement.addEventListener(EVENTS.CLICK, this.handleNewLoginVaultItemAction);
return this.buildButtonContainer(this.newItemButtonElement);
}
@@ -581,7 +611,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
* Handles the click event for the new item button.
* Sends a message to the parent window to add a new vault item.
*/
private handeNewItemButtonClick = () => {
private handleNewLoginVaultItemAction = () => {
let addNewCipherType = this.inlineMenuFillType;
if (this.showInlineMenuAccountCreation) {

View File

@@ -45,6 +45,14 @@ body * {
&.no-items,
&.save-login {
font-size: 1.6rem;
&:has(:focus-visible) {
outline-width: 0.2rem;
outline-style: solid;
@include themify($themes) {
outline-color: themed("focusOutlineColor");
}
}
}
}

View File

@@ -43,10 +43,7 @@
</a>
</bit-hint>
</bit-form-control>
<bit-form-control
*ngIf="inlineMenuPositioningImprovementsEnabled && enableInlineMenu"
class="tw-ml-5"
>
<bit-form-control *ngIf="enableInlineMenu" class="tw-ml-5">
<input
bitCheckbox
id="show-inline-menu-identities"
@@ -58,10 +55,7 @@
{{ "showInlineMenuIdentitiesLabel" | i18n }}
</bit-label>
</bit-form-control>
<bit-form-control
*ngIf="inlineMenuPositioningImprovementsEnabled && enableInlineMenu"
class="tw-ml-5"
>
<bit-form-control *ngIf="enableInlineMenu" class="tw-ml-5">
<input
bitCheckbox
id="show-inline-menu-cards"

View File

@@ -92,7 +92,6 @@ export class AutofillComponent implements OnInit {
protected defaultBrowserAutofillDisabled: boolean = false;
protected inlineMenuVisibility: InlineMenuVisibilitySetting =
AutofillOverlayVisibility.OnFieldFocus;
protected inlineMenuPositioningImprovementsEnabled: boolean = false;
protected blockBrowserInjectionsByDomainEnabled: boolean = false;
protected browserClientVendor: BrowserClientVendor = BrowserClientVendors.Unknown;
protected disablePasswordManagerURI: DisablePasswordManagerUri =
@@ -180,21 +179,17 @@ export class AutofillComponent implements OnInit {
this.autofillSettingsService.inlineMenuVisibility$,
);
this.inlineMenuPositioningImprovementsEnabled = await this.configService.getFeatureFlag(
FeatureFlag.InlineMenuPositioningImprovements,
);
this.blockBrowserInjectionsByDomainEnabled = await this.configService.getFeatureFlag(
FeatureFlag.BlockBrowserInjectionsByDomain,
);
this.showInlineMenuIdentities =
this.inlineMenuPositioningImprovementsEnabled &&
(await firstValueFrom(this.autofillSettingsService.showInlineMenuIdentities$));
this.showInlineMenuIdentities = await firstValueFrom(
this.autofillSettingsService.showInlineMenuIdentities$,
);
this.showInlineMenuCards =
this.inlineMenuPositioningImprovementsEnabled &&
(await firstValueFrom(this.autofillSettingsService.showInlineMenuCards$));
this.showInlineMenuCards = await firstValueFrom(
this.autofillSettingsService.showInlineMenuCards$,
);
this.enableInlineMenuOnIconSelect =
this.inlineMenuVisibility === AutofillOverlayVisibility.OnButtonClick;

View File

@@ -284,15 +284,6 @@ export default class AutofillService implements AutofillServiceInterface {
inlineMenuVisibility = await this.getInlineMenuVisibility();
}
const inlineMenuPositioningImprovements = await this.configService.getFeatureFlag(
FeatureFlag.InlineMenuPositioningImprovements,
);
if (!inlineMenuPositioningImprovements) {
return !inlineMenuVisibility
? "bootstrap-autofill.js"
: "bootstrap-legacy-autofill-overlay.js";
}
const enableChangedPasswordPrompt = await firstValueFrom(
this.userNotificationSettingsService.enableChangedPasswordPrompt$,
);

View File

@@ -192,6 +192,10 @@ import { InternalFolderService as InternalFolderServiceAbstraction } from "@bitw
import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service";
import { VaultSettingsService as VaultSettingsServiceAbstraction } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
DefaultEndUserNotificationService,
EndUserNotificationService,
} from "@bitwarden/common/vault/notifications";
import {
CipherAuthorizationService,
DefaultCipherAuthorizationService,
@@ -247,7 +251,6 @@ import WebRequestBackground from "../autofill/background/web-request.background"
import { CipherContextMenuHandler } from "../autofill/browser/cipher-context-menu-handler";
import { ContextMenuClickedHandler } from "../autofill/browser/context-menu-clicked-handler";
import { MainContextMenuHandler } from "../autofill/browser/main-context-menu-handler";
import LegacyOverlayBackground from "../autofill/deprecated/background/overlay.background.deprecated";
import { Fido2Background as Fido2BackgroundAbstraction } from "../autofill/fido2/background/abstractions/fido2.background";
import { Fido2Background } from "../autofill/fido2/background/fido2.background";
import {
@@ -405,6 +408,7 @@ export default class MainBackground {
sdkService: SdkService;
sdkLoadService: SdkLoadService;
cipherAuthorizationService: CipherAuthorizationService;
endUserNotificationService: EndUserNotificationService;
inlineMenuFieldQualificationService: InlineMenuFieldQualificationService;
taskService: TaskService;
cipherEncryptionService: CipherEncryptionService;
@@ -1332,6 +1336,14 @@ export default class MainBackground {
this.ipcContentScriptManagerService = new IpcContentScriptManagerService(this.configService);
this.ipcService = new IpcBackgroundService(this.logService);
this.endUserNotificationService = new DefaultEndUserNotificationService(
this.stateProvider,
this.apiService,
this.notificationsService,
this.authService,
this.logService,
);
}
async bootstrap() {
@@ -1418,6 +1430,9 @@ export default class MainBackground {
this.taskService.listenForTaskNotifications();
}
if (await this.configService.getFeatureFlag(FeatureFlag.EndUserNotifications)) {
this.endUserNotificationService.listenForEndUserNotifications();
}
resolve();
}, 500);
});
@@ -1727,45 +1742,26 @@ export default class MainBackground {
return;
}
const inlineMenuPositioningImprovementsEnabled = await this.configService.getFeatureFlag(
FeatureFlag.InlineMenuPositioningImprovements,
this.overlayBackground = new OverlayBackground(
this.logService,
this.cipherService,
this.autofillService,
this.authService,
this.environmentService,
this.domainSettingsService,
this.autofillSettingsService,
this.i18nService,
this.platformUtilsService,
this.vaultSettingsService,
this.fido2ActiveRequestManager,
this.inlineMenuFieldQualificationService,
this.themeStateService,
this.totpService,
this.accountService,
() => this.generatePassword(),
(password) => this.addPasswordToHistory(password),
);
if (!inlineMenuPositioningImprovementsEnabled) {
this.overlayBackground = new LegacyOverlayBackground(
this.cipherService,
this.autofillService,
this.authService,
this.environmentService,
this.domainSettingsService,
this.autofillSettingsService,
this.i18nService,
this.platformUtilsService,
this.themeStateService,
this.accountService,
);
} else {
this.overlayBackground = new OverlayBackground(
this.logService,
this.cipherService,
this.autofillService,
this.authService,
this.environmentService,
this.domainSettingsService,
this.autofillSettingsService,
this.i18nService,
this.platformUtilsService,
this.vaultSettingsService,
this.fido2ActiveRequestManager,
this.inlineMenuFieldQualificationService,
this.themeStateService,
this.totpService,
this.accountService,
() => this.generatePassword(),
(password) => this.addPasswordToHistory(password),
);
}
this.tabsBackground = new TabsBackground(
this,
this.notificationBackground,

View File

@@ -1,6 +1,6 @@
import { Component } from "@angular/core";
import { RemovePasswordComponent as BaseRemovePasswordComponent } from "@bitwarden/angular/auth/components/remove-password.component";
import { RemovePasswordComponent as BaseRemovePasswordComponent } from "@bitwarden/key-management-ui";
@Component({
selector: "app-remove-password",

View File

@@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "__MSG_extName__",
"short_name": "Bitwarden",
"version": "2025.3.2",
"version": "2025.4.0",
"description": "__MSG_extDesc__",
"default_locale": "en",
"author": "Bitwarden Inc.",
@@ -79,12 +79,7 @@
"__safari__optional_permissions": null,
"content_security_policy": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'",
"sandbox": {
"pages": [
"overlay/menu-button.html",
"overlay/menu-list.html",
"overlay/button.html",
"overlay/list.html"
],
"pages": ["overlay/menu-button.html", "overlay/menu-list.html"],
"content_security_policy": "sandbox allow-scripts; script-src 'self'"
},
"__firefox__sandbox": null,
@@ -140,8 +135,6 @@
"overlay/menu-button.html",
"overlay/menu-list.html",
"overlay/menu.html",
"overlay/button.html",
"overlay/list.html",
"popup/fonts/*"
],
"__firefox__browser_specific_settings": {

View File

@@ -3,7 +3,7 @@
"minimum_chrome_version": "102.0",
"name": "__MSG_extName__",
"short_name": "Bitwarden",
"version": "2025.3.2",
"version": "2025.4.0",
"description": "__MSG_extDesc__",
"default_locale": "en",
"author": "Bitwarden Inc.",
@@ -105,12 +105,7 @@
"__chrome__sandbox": "sandbox allow-scripts; script-src 'self'"
},
"sandbox": {
"pages": [
"overlay/menu-button.html",
"overlay/menu-list.html",
"overlay/button.html",
"overlay/list.html"
]
"pages": ["overlay/menu-button.html", "overlay/menu-list.html"]
},
"__firefox__sandbox": null,
"commands": {
@@ -167,8 +162,6 @@
"overlay/menu-button.html",
"overlay/menu-list.html",
"overlay/menu.html",
"overlay/button.html",
"overlay/list.html",
"popup/fonts/*"
],
"matches": ["<all_urls>"]

View File

@@ -18,10 +18,11 @@ export class BackgroundCommunicationBackend implements CommunicationBackend {
return;
}
void this.queue.enqueue({
...message.message,
source: { Web: { id: sender.tab.id } },
} as any);
void this.queue.enqueue(
new IncomingMessage(message.message.payload, message.message.destination, {
Web: { id: sender.tab.id },
}),
);
});
}

View File

@@ -55,7 +55,6 @@ import {
ExtensionAnonLayoutWrapperComponent,
ExtensionAnonLayoutWrapperData,
} from "../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component";
import { RemovePasswordComponent } from "../auth/popup/remove-password.component";
import { SetPasswordComponent } from "../auth/popup/set-password.component";
import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component";
import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component";
@@ -65,6 +64,7 @@ import { BlockedDomainsComponent } from "../autofill/popup/settings/blocked-doma
import { ExcludedDomainsComponent } from "../autofill/popup/settings/excluded-domains.component";
import { NotificationsSettingsComponent } from "../autofill/popup/settings/notifications.component";
import { PremiumV2Component } from "../billing/popup/settings/premium-v2.component";
import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component";
import BrowserPopupUtils from "../platform/popup/browser-popup-utils";
import { popupRouterCacheGuard } from "../platform/popup/view-cache/popup-router-cache.service";
import { CredentialGeneratorHistoryComponent } from "../tools/popup/generator/credential-generator-history.component";
@@ -593,6 +593,7 @@ const routes: Routes = [
path: "intro-carousel",
component: ExtensionAnonLayoutWrapperComponent,
canActivate: [],
data: { elevation: 0, doNotSaveUrl: true } satisfies RouteDataProperties,
children: [
{
path: "",

View File

@@ -25,13 +25,13 @@ import {
import { AccountComponent } from "../auth/popup/account-switching/account.component";
import { CurrentAccountComponent } from "../auth/popup/account-switching/current-account.component";
import { ExtensionAnonLayoutWrapperComponent } from "../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component";
import { RemovePasswordComponent } from "../auth/popup/remove-password.component";
import { SetPasswordComponent } from "../auth/popup/set-password.component";
import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component";
import { VaultTimeoutInputComponent } from "../auth/popup/settings/vault-timeout-input.component";
import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component";
import { AutofillComponent } from "../autofill/popup/settings/autofill.component";
import { NotificationsSettingsComponent } from "../autofill/popup/settings/notifications.component";
import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component";
import { PopOutComponent } from "../platform/popup/components/pop-out.component";
import { HeaderComponent } from "../platform/popup/header.component";
import { PopupFooterComponent } from "../platform/popup/layout/popup-footer.component";

View File

@@ -12,10 +12,13 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types";
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";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { EndUserNotificationService } from "@bitwarden/common/vault/notifications";
import { NotificationView } from "@bitwarden/common/vault/notifications/models";
import { SecurityTask, SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
import { DialogService, ToastService } from "@bitwarden/components";
import {
@@ -66,6 +69,7 @@ describe("AtRiskPasswordsComponent", () => {
let mockTasks$: BehaviorSubject<SecurityTask[]>;
let mockCiphers$: BehaviorSubject<CipherView[]>;
let mockOrgs$: BehaviorSubject<Organization[]>;
let mockNotifications$: BehaviorSubject<NotificationView[]>;
let mockInlineMenuVisibility$: BehaviorSubject<InlineMenuVisibilitySetting>;
let calloutDismissed$: BehaviorSubject<boolean>;
const setInlineMenuVisibility = jest.fn();
@@ -73,6 +77,7 @@ describe("AtRiskPasswordsComponent", () => {
const mockAtRiskPasswordPageService = mock<AtRiskPasswordPageService>();
const mockChangeLoginPasswordService = mock<ChangeLoginPasswordService>();
const mockDialogService = mock<DialogService>();
const mockConfigService = mock<ConfigService>();
beforeEach(async () => {
mockTasks$ = new BehaviorSubject<SecurityTask[]>([
@@ -101,6 +106,7 @@ describe("AtRiskPasswordsComponent", () => {
name: "Org 1",
} as Organization,
]);
mockNotifications$ = new BehaviorSubject<NotificationView[]>([]);
mockInlineMenuVisibility$ = new BehaviorSubject<InlineMenuVisibilitySetting>(
AutofillOverlayVisibility.Off,
@@ -110,6 +116,7 @@ describe("AtRiskPasswordsComponent", () => {
setInlineMenuVisibility.mockClear();
mockToastService.showToast.mockClear();
mockDialogService.open.mockClear();
mockConfigService.getFeatureFlag.mockClear();
mockAtRiskPasswordPageService.isCalloutDismissed.mockReturnValue(calloutDismissed$);
await TestBed.configureTestingModule({
@@ -133,6 +140,12 @@ describe("AtRiskPasswordsComponent", () => {
cipherViews$: () => mockCiphers$,
},
},
{
provide: EndUserNotificationService,
useValue: {
unreadNotifications$: () => mockNotifications$,
},
},
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: AccountService, useValue: { activeAccount$: of({ id: "user" }) } },
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
@@ -145,6 +158,7 @@ describe("AtRiskPasswordsComponent", () => {
},
},
{ provide: ToastService, useValue: mockToastService },
{ provide: ConfigService, useValue: mockConfigService },
],
})
.overrideModule(JslibModule, {

View File

@@ -1,7 +1,19 @@
import { CommonModule } from "@angular/common";
import { Component, inject, OnInit, signal } from "@angular/core";
import { Component, DestroyRef, inject, OnInit, signal } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Router } from "@angular/router";
import { combineLatest, firstValueFrom, map, of, shareReplay, startWith, switchMap } from "rxjs";
import {
combineLatest,
concat,
concatMap,
firstValueFrom,
map,
of,
shareReplay,
startWith,
switchMap,
take,
} from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
@@ -11,10 +23,13 @@ import {
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { EndUserNotificationService } from "@bitwarden/common/vault/notifications";
import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
import {
@@ -81,6 +96,9 @@ export class AtRiskPasswordsComponent implements OnInit {
private changeLoginPasswordService = inject(ChangeLoginPasswordService);
private platformUtilsService = inject(PlatformUtilsService);
private dialogService = inject(DialogService);
private endUserNotificationService = inject(EndUserNotificationService);
private configService = inject(ConfigService);
private destroyRef = inject(DestroyRef);
/**
* The cipher that is currently being launched. Used to show a loading spinner on the badge button.
@@ -180,6 +198,36 @@ export class AtRiskPasswordsComponent implements OnInit {
await this.atRiskPasswordPageService.dismissGettingStarted(userId);
}
}
if (await this.configService.getFeatureFlag(FeatureFlag.EndUserNotifications)) {
this.markTaskNotificationsAsRead();
}
}
private markTaskNotificationsAsRead() {
this.activeUserData$
.pipe(
switchMap(({ tasks, userId }) => {
return this.endUserNotificationService.unreadNotifications$(userId).pipe(
take(1),
map((notifications) => {
return notifications.filter((notification) => {
return tasks.some((task) => task.id === notification.taskId);
});
}),
concatMap((unreadTaskNotifications) => {
// TODO: Investigate creating a bulk endpoint to mark notifications as read
return concat(
...unreadTaskNotifications.map((n) =>
this.endUserNotificationService.markAsRead(n.id, userId),
),
);
}),
);
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
}
async viewCipher(cipher: CipherView) {

View File

@@ -43,7 +43,7 @@
bitButton
buttonType="secondary"
(click)="navigateToLogin()"
class="tw-w-full tw-mt-4"
class="tw-w-full tw-mt-2"
>
{{ "logIn" | i18n }}
</button>

Some files were not shown because too many files have changed in this diff Show More