mirror of
https://github.com/bitwarden/clients.git
synced 2025-12-05 19:17:06 -06:00
Compare commits
76 Commits
0cfa59d53e
...
3c46db399e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c46db399e | ||
|
|
d12c098933 | ||
|
|
09f68e3a17 | ||
|
|
2976c9c654 | ||
|
|
d32365fbba | ||
|
|
2bf9e3f6df | ||
|
|
cf806dcac4 | ||
|
|
474ffa2ce1 | ||
|
|
ad12704c21 | ||
|
|
4155e26c28 | ||
|
|
5386b58f23 | ||
|
|
b9cb19a98e | ||
|
|
2ef84ca460 | ||
|
|
433c0ced32 | ||
|
|
77fe04f8c7 | ||
|
|
d581f06b32 | ||
|
|
dab1a37bfe | ||
|
|
04d7744747 | ||
|
|
28fbddb63f | ||
|
|
d64da69fa7 | ||
|
|
1bfff49ef5 | ||
|
|
6e2203d6d4 | ||
|
|
6ae096485a | ||
|
|
5f9759fde1 | ||
|
|
422e527516 | ||
|
|
17ebae11d7 | ||
|
|
a6100d8a0e | ||
|
|
cf416388d7 | ||
|
|
6f9b25e98e | ||
|
|
dc953b3945 | ||
|
|
365af52e33 | ||
|
|
dd623b136b | ||
|
|
44e3320a67 | ||
|
|
30f615767c | ||
|
|
d373eefc9d | ||
|
|
57b6d8ba58 | ||
|
|
dd99190ca2 | ||
|
|
92709e63af | ||
|
|
12222e39b4 | ||
|
|
aa309e4e56 | ||
|
|
f17890a26b | ||
|
|
ebd5793568 | ||
|
|
bf461879e3 | ||
|
|
bbb5acba50 | ||
|
|
a9bf66e689 | ||
|
|
049acf1e12 | ||
|
|
2e8faa9994 | ||
|
|
2510844293 | ||
|
|
4a4ce8312c | ||
|
|
406dbc8066 | ||
|
|
37b233aad9 | ||
|
|
d4c62495b3 | ||
|
|
99186e3651 | ||
|
|
e694ab490c | ||
|
|
aac7ca172b | ||
|
|
399a5147a9 | ||
|
|
10424e227b | ||
|
|
79d518fcf7 | ||
|
|
ee03c8a36a | ||
|
|
d05356dbeb | ||
|
|
be00be8fd8 | ||
|
|
963a9156fb | ||
|
|
4a2858132d | ||
|
|
30b89d1fc2 | ||
|
|
b9d5724312 | ||
|
|
e1d14ca7bd | ||
|
|
d0690ebc52 | ||
|
|
9936330971 | ||
|
|
39a2d80b10 | ||
|
|
fc63c0c2cf | ||
|
|
b7287d4614 | ||
|
|
b248341d0e | ||
|
|
2fd4a92cc5 | ||
|
|
39a22113df | ||
|
|
95def44097 | ||
|
|
a8d6ad4db6 |
10
.github/renovate.json5
vendored
10
.github/renovate.json5
vendored
@@ -119,7 +119,7 @@
|
||||
"rimraf",
|
||||
"ssh-encoding",
|
||||
"ssh-key",
|
||||
"@storybook/web-components-webpack5",
|
||||
"@storybook/web-components-vite",
|
||||
"tabbable",
|
||||
"tldts",
|
||||
"wait-on",
|
||||
@@ -311,26 +311,24 @@
|
||||
"@compodoc/compodoc",
|
||||
"@ng-select/ng-select",
|
||||
"@storybook/addon-a11y",
|
||||
"@storybook/addon-actions",
|
||||
"@storybook/addon-designs",
|
||||
"@storybook/addon-essentials",
|
||||
"@storybook/addon-interactions",
|
||||
"@storybook/addon-docs",
|
||||
"@storybook/addon-links",
|
||||
"@storybook/test-runner",
|
||||
"@storybook/addon-themes",
|
||||
"@storybook/angular",
|
||||
"@storybook/manager-api",
|
||||
"@storybook/theming",
|
||||
"@types/react",
|
||||
"autoprefixer",
|
||||
"bootstrap",
|
||||
"chromatic",
|
||||
"ngx-toastr",
|
||||
"path-browserify",
|
||||
"react",
|
||||
"react-dom",
|
||||
"remark-gfm",
|
||||
"storybook",
|
||||
"tailwindcss",
|
||||
"vite-tsconfig-paths",
|
||||
"zone.js",
|
||||
"@tailwindcss/container-queries",
|
||||
],
|
||||
|
||||
36
.github/workflows/build-desktop.yml
vendored
36
.github/workflows/build-desktop.yml
vendored
@@ -175,9 +175,23 @@ jobs:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Free disk space for build
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo rm -rf /usr/share/swift
|
||||
sudo rm -rf /usr/local/.ghcup
|
||||
sudo rm -rf /usr/share/miniconda
|
||||
sudo rm -rf /usr/share/az_*
|
||||
sudo rm -rf /usr/local/julia*
|
||||
sudo rm -rf /usr/lib/mono
|
||||
sudo rm -rf /usr/lib/heroku
|
||||
sudo rm -rf /usr/local/aws-cli
|
||||
sudo rm -rf /usr/local/aws-sam-cli
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
@@ -186,7 +200,7 @@ jobs:
|
||||
node-version: ${{ env._NODE_VERSION }}
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
|
||||
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
|
||||
with:
|
||||
workspaces: |
|
||||
apps/desktop/desktop_native -> target
|
||||
@@ -249,9 +263,11 @@ jobs:
|
||||
PKG_CONFIG_ALLOW_CROSS: true
|
||||
PKG_CONFIG_ALL_STATIC: true
|
||||
TARGET: musl
|
||||
# Note: It is important that we use the release build because some compute heavy
|
||||
# operations such as key derivation for oo7 on linux are too slow in debug mode
|
||||
run: |
|
||||
rustup target add x86_64-unknown-linux-musl
|
||||
node build.js --target=x86_64-unknown-linux-musl
|
||||
node build.js --target=x86_64-unknown-linux-musl --release
|
||||
|
||||
- name: Build application
|
||||
run: npm run dist:lin
|
||||
@@ -342,7 +358,7 @@ jobs:
|
||||
node-version: ${{ env._NODE_VERSION }}
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
|
||||
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
|
||||
with:
|
||||
workspaces: |
|
||||
apps/desktop/desktop_native -> target
|
||||
@@ -412,9 +428,11 @@ jobs:
|
||||
PKG_CONFIG_ALLOW_CROSS: true
|
||||
PKG_CONFIG_ALL_STATIC: true
|
||||
TARGET: musl
|
||||
# Note: It is important that we use the release build because some compute heavy
|
||||
# operations such as key derivation for oo7 on linux are too slow in debug mode
|
||||
run: |
|
||||
rustup target add aarch64-unknown-linux-musl
|
||||
node build.js --target=aarch64-unknown-linux-musl
|
||||
node build.js --target=aarch64-unknown-linux-musl --release
|
||||
|
||||
- name: Check index.d.ts generated
|
||||
if: github.event_name == 'pull_request' && steps.cache.outputs.cache-hit != 'true'
|
||||
@@ -490,7 +508,7 @@ jobs:
|
||||
node-version: ${{ env._NODE_VERSION }}
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
|
||||
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
|
||||
with:
|
||||
workspaces: |
|
||||
apps/desktop/desktop_native -> target
|
||||
@@ -756,7 +774,7 @@ jobs:
|
||||
node-version: ${{ env._NODE_VERSION }}
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
|
||||
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
|
||||
with:
|
||||
workspaces: |
|
||||
apps/desktop/desktop_native -> target
|
||||
@@ -1007,7 +1025,7 @@ jobs:
|
||||
run: python3 -m pip install setuptools
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
|
||||
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
|
||||
with:
|
||||
workspaces: |
|
||||
apps/desktop/desktop_native -> target
|
||||
@@ -1244,7 +1262,7 @@ jobs:
|
||||
run: python3 -m pip install setuptools
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
|
||||
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
|
||||
with:
|
||||
workspaces: |
|
||||
apps/desktop/desktop_native -> target
|
||||
@@ -1516,7 +1534,7 @@ jobs:
|
||||
run: python3 -m pip install setuptools
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
|
||||
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
|
||||
with:
|
||||
workspaces: |
|
||||
apps/desktop/desktop_native -> target
|
||||
|
||||
24
.github/workflows/publish-web.yml
vendored
24
.github/workflows/publish-web.yml
vendored
@@ -158,7 +158,7 @@ jobs:
|
||||
run: docker logout
|
||||
|
||||
bitwarden-lite-build:
|
||||
name: Trigger Bitwarden Lite build
|
||||
name: Trigger Bitwarden lite build
|
||||
runs-on: ubuntu-22.04
|
||||
needs: setup
|
||||
permissions:
|
||||
@@ -171,20 +171,27 @@ jobs:
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Retrieve GitHub PAT secrets
|
||||
id: retrieve-secret-pat
|
||||
- name: Get Azure Key Vault secrets
|
||||
id: get-kv-secrets
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: "bitwarden-ci"
|
||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
||||
keyvault: gh-org-bitwarden
|
||||
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Trigger Bitwarden Lite build
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
|
||||
|
||||
- name: Trigger Bitwarden lite build
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
await github.rest.actions.createWorkflowDispatch({
|
||||
owner: 'bitwarden',
|
||||
@@ -192,6 +199,7 @@ jobs:
|
||||
workflow_id: 'build-bitwarden-lite.yml',
|
||||
ref: 'main',
|
||||
inputs: {
|
||||
use_latest_core_version: true
|
||||
use_latest_core_version: true,
|
||||
web_branch: process.env.GITHUB_REF
|
||||
}
|
||||
});
|
||||
|
||||
6
.github/workflows/repository-management.yml
vendored
6
.github/workflows/repository-management.yml
vendored
@@ -29,7 +29,7 @@ on:
|
||||
default: false
|
||||
target_ref:
|
||||
default: "main"
|
||||
description: "Branch/Tag to target for cut"
|
||||
description: "Branch/Tag to target for cut (ignored if not cutting rc)"
|
||||
required: true
|
||||
type: string
|
||||
version_number_override:
|
||||
@@ -102,11 +102,12 @@ jobs:
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
|
||||
permission-contents: write # for committing and pushing to current branch
|
||||
|
||||
- name: Check out branch
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
ref: main
|
||||
ref: ${{ github.ref }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
persist-credentials: true
|
||||
|
||||
@@ -467,6 +468,7 @@ jobs:
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
|
||||
permission-contents: write # for creating and pushing new branch
|
||||
|
||||
- name: Check out target ref
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -148,7 +148,7 @@ jobs:
|
||||
components: llvm-tools
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
|
||||
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
|
||||
with:
|
||||
workspaces: "apps/desktop/desktop_native -> target"
|
||||
|
||||
|
||||
@@ -28,15 +28,13 @@ const config: StorybookConfig = {
|
||||
],
|
||||
addons: [
|
||||
getAbsolutePath("@storybook/addon-links"),
|
||||
getAbsolutePath("@storybook/addon-essentials"),
|
||||
getAbsolutePath("@storybook/addon-a11y"),
|
||||
getAbsolutePath("@storybook/addon-designs"),
|
||||
getAbsolutePath("@storybook/addon-interactions"),
|
||||
getAbsolutePath("@storybook/addon-themes"),
|
||||
{
|
||||
// @storybook/addon-docs is part of @storybook/addon-essentials
|
||||
// eslint-disable-next-line storybook/no-uninstalled-addons
|
||||
name: "@storybook/addon-docs",
|
||||
|
||||
name: getAbsolutePath("@storybook/addon-docs"),
|
||||
options: {
|
||||
mdxPluginOptions: {
|
||||
mdxCompileOptions: {
|
||||
@@ -60,6 +58,10 @@ const config: StorybookConfig = {
|
||||
webpackFinal: async (config, { configType }) => {
|
||||
if (config.resolve) {
|
||||
config.resolve.plugins = [new TsconfigPathsPlugin()] as any;
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
path: require.resolve("path-browserify"),
|
||||
};
|
||||
}
|
||||
return config;
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { addons } from "@storybook/manager-api";
|
||||
import { create } from "@storybook/theming/create";
|
||||
import { addons } from "storybook/manager-api";
|
||||
import { create } from "storybook/theming";
|
||||
|
||||
const lightTheme = create({
|
||||
base: "light",
|
||||
|
||||
@@ -28,7 +28,7 @@ const preview: Preview = {
|
||||
],
|
||||
parameters: {
|
||||
a11y: {
|
||||
element: "#storybook-root",
|
||||
context: "#storybook-root",
|
||||
},
|
||||
controls: {
|
||||
matchers: {
|
||||
@@ -49,7 +49,7 @@ const preview: Preview = {
|
||||
},
|
||||
},
|
||||
backgrounds: {
|
||||
disable: true,
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
|
||||
26
angular.json
26
angular.json
@@ -220,5 +220,31 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"type": "component"
|
||||
},
|
||||
"@schematics/angular:directive": {
|
||||
"type": "directive"
|
||||
},
|
||||
"@schematics/angular:service": {
|
||||
"type": "service"
|
||||
},
|
||||
"@schematics/angular:guard": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:interceptor": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:module": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:pipe": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:resolver": {
|
||||
"typeSeparator": "."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@bitwarden/browser",
|
||||
"version": "2025.11.2",
|
||||
"version": "2025.12.0",
|
||||
"scripts": {
|
||||
"build": "npm run build:chrome",
|
||||
"build:bit": "npm run build:bit:chrome",
|
||||
|
||||
@@ -562,7 +562,7 @@
|
||||
"description": "Verb"
|
||||
},
|
||||
"unArchive": {
|
||||
"message": "Nicht mehr archivieren"
|
||||
"message": "Wiederherstellen"
|
||||
},
|
||||
"itemsInArchive": {
|
||||
"message": "Einträge im Archiv"
|
||||
@@ -574,10 +574,10 @@
|
||||
"message": "Archivierte Einträge werden hier angezeigt und von allgemeinen Suchergebnissen sowie Vorschlägen zum automatischen Ausfüllen ausgeschlossen."
|
||||
},
|
||||
"itemWasSentToArchive": {
|
||||
"message": "Eintrag wurde ins Archiv verschoben"
|
||||
"message": "Eintrag wurde archiviert"
|
||||
},
|
||||
"itemUnarchived": {
|
||||
"message": "Eintrag wird nicht mehr archiviert"
|
||||
"message": "Eintrag wurde wiederhergestellt"
|
||||
},
|
||||
"archiveItem": {
|
||||
"message": "Eintrag archivieren"
|
||||
@@ -1050,7 +1050,7 @@
|
||||
"message": "Eintrag gespeichert"
|
||||
},
|
||||
"savedWebsite": {
|
||||
"message": "Website gespeichert"
|
||||
"message": "Gespeicherte Website"
|
||||
},
|
||||
"savedWebsites": {
|
||||
"message": "Gespeicherte Websites ($COUNT$)",
|
||||
@@ -1710,7 +1710,7 @@
|
||||
"message": "Auto-Ausfüllen bestätigen"
|
||||
},
|
||||
"confirmAutofillDesc": {
|
||||
"message": "Diese Website stimmt nicht mit deinen gespeicherten Zugangsdaten überein. Bevor du deine Zugangsdaten eingibst, stelle sicher, dass es sich um eine vertrauenswürdige Website handelt."
|
||||
"message": "Diese Website stimmt nicht mit deinen gespeicherten Zugangsdaten überein. Stelle sicher, dass dies eine vertrauenswürdige Website ist, bevor du deine Zugangsdaten eingibst."
|
||||
},
|
||||
"showInlineMenuLabel": {
|
||||
"message": "Vorschläge zum Auto-Ausfüllen in Formularfeldern anzeigen"
|
||||
@@ -1874,7 +1874,7 @@
|
||||
"message": "Ablaufjahr"
|
||||
},
|
||||
"monthly": {
|
||||
"message": "month"
|
||||
"message": "Monatlich"
|
||||
},
|
||||
"expiration": {
|
||||
"message": "Gültig bis"
|
||||
@@ -2446,7 +2446,7 @@
|
||||
}
|
||||
},
|
||||
"topLayerHijackWarning": {
|
||||
"message": "Diese Seite beeinträchtigt die Nutzung von Bitwarden. Das Bitwarden Inline-Menü wurde aus Sicherheitsgründen vorübergehend deaktiviert."
|
||||
"message": "Diese Seite stört die Bitwarden-Nutzung. Das Bitwarden Inline-Menü wurde aus Sicherheitsgründen vorübergehend deaktiviert."
|
||||
},
|
||||
"setMasterPassword": {
|
||||
"message": "Master-Passwort festlegen"
|
||||
@@ -4075,7 +4075,7 @@
|
||||
"message": "Kein Auto-Ausfüllen möglich"
|
||||
},
|
||||
"cannotAutofillExactMatch": {
|
||||
"message": "Die Standard-Übereinstimmungserkennung steht auf \"Exakte Übereinstimmung\". Die aktuelle Website stimmt nicht genau mit den gespeicherten Zugangsdaten für diesen Eintrag überein."
|
||||
"message": "Die Standard-Übereinstimmungserkennung ist auf „Exakte Übereinstimmung“ eingestellt. Die aktuelle Website stimmt nicht genau mit den gespeicherten Zugangsdaten für diesen Eintrag überein."
|
||||
},
|
||||
"okay": {
|
||||
"message": "Okay"
|
||||
@@ -5665,7 +5665,7 @@
|
||||
"message": "Phishing-Versuch erkannt"
|
||||
},
|
||||
"phishingPageSummary": {
|
||||
"message": "Die Website, die du versuchst zu öffnen, ist eine bekannte böswillige Website und ein Sicherheitsrisiko."
|
||||
"message": "Die Website, die du öffnen möchtest, ist als böswillige Website bekannt und stellt ein Sicherheitsrisiko dar."
|
||||
},
|
||||
"phishingPageCloseTabV2": {
|
||||
"message": "Diesen Tab schließen"
|
||||
@@ -5813,7 +5813,7 @@
|
||||
"message": "Notfallzugriff"
|
||||
},
|
||||
"breachMonitoring": {
|
||||
"message": "Datendiebstahl-Überwachung"
|
||||
"message": "Datenleck-Überwachung"
|
||||
},
|
||||
"andMoreFeatures": {
|
||||
"message": "Und mehr!"
|
||||
|
||||
@@ -1406,6 +1406,27 @@
|
||||
"learnMore": {
|
||||
"message": "Learn more"
|
||||
},
|
||||
"migrationsFailed": {
|
||||
"message": "An error occurred updating the encryption settings."
|
||||
},
|
||||
"updateEncryptionSettingsTitle": {
|
||||
"message": "Update your encryption settings"
|
||||
},
|
||||
"updateEncryptionSettingsDesc": {
|
||||
"message": "The new recommended encryption settings will improve your account security. Enter your master password to update now."
|
||||
},
|
||||
"confirmIdentityToContinue": {
|
||||
"message": "Confirm your identity to continue"
|
||||
},
|
||||
"enterYourMasterPassword": {
|
||||
"message": "Enter your master password"
|
||||
},
|
||||
"updateSettings": {
|
||||
"message": "Update settings"
|
||||
},
|
||||
"later": {
|
||||
"message": "Later"
|
||||
},
|
||||
"authenticatorKeyTotp": {
|
||||
"message": "Authenticator key (TOTP)"
|
||||
},
|
||||
@@ -1475,6 +1496,15 @@
|
||||
"ppremiumSignUpStorage": {
|
||||
"message": "1 GB encrypted storage for file attachments."
|
||||
},
|
||||
"premiumSignUpStorageV2": {
|
||||
"message": "$SIZE$ encrypted storage for file attachments.",
|
||||
"placeholders": {
|
||||
"size": {
|
||||
"content": "$1",
|
||||
"example": "1 GB"
|
||||
}
|
||||
}
|
||||
},
|
||||
"premiumSignUpEmergency": {
|
||||
"message": "Emergency access."
|
||||
},
|
||||
|
||||
@@ -861,7 +861,7 @@
|
||||
"message": "A confirmação da senha principal não corresponde."
|
||||
},
|
||||
"newAccountCreated": {
|
||||
"message": "A sua nova conta foi criada! Agora você pode conectar-se."
|
||||
"message": "A sua conta nova foi criada! Agora você pode se conectar."
|
||||
},
|
||||
"newAccountCreated2": {
|
||||
"message": "Sua nova conta foi criada!"
|
||||
@@ -870,7 +870,7 @@
|
||||
"message": "Você foi conectado!"
|
||||
},
|
||||
"youSuccessfullyLoggedIn": {
|
||||
"message": "Você conectou-se à sua conta com sucesso"
|
||||
"message": "Você se conectou com sucesso"
|
||||
},
|
||||
"youMayCloseThisWindow": {
|
||||
"message": "Você pode fechar esta janela"
|
||||
@@ -2482,16 +2482,16 @@
|
||||
}
|
||||
},
|
||||
"policyInEffectUppercase": {
|
||||
"message": "Contém um ou mais caracteres em maiúsculo"
|
||||
"message": "Conter um ou mais caracteres em maiúsculo"
|
||||
},
|
||||
"policyInEffectLowercase": {
|
||||
"message": "Contém um ou mais caracteres em minúsculo"
|
||||
"message": "Conter um ou mais caracteres em minúsculo"
|
||||
},
|
||||
"policyInEffectNumbers": {
|
||||
"message": "Contém um ou mais números"
|
||||
"message": "Conter um ou mais números"
|
||||
},
|
||||
"policyInEffectSpecial": {
|
||||
"message": "Contém um ou mais dos seguintes caracteres especiais $CHARS$",
|
||||
"message": "Conter um ou mais dos seguintes caracteres especiais $CHARS$",
|
||||
"placeholders": {
|
||||
"chars": {
|
||||
"content": "$1",
|
||||
@@ -3308,7 +3308,7 @@
|
||||
"description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'"
|
||||
},
|
||||
"contactCSToAvoidDataLossPart2": {
|
||||
"message": "para evitar a perca adicional dos dados.",
|
||||
"message": "para evitar a perca de dados adicionais.",
|
||||
"description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'"
|
||||
},
|
||||
"generateUsername": {
|
||||
|
||||
@@ -586,7 +586,7 @@
|
||||
"message": "Arkiverade objekt är exkluderade från allmänna sökresultat och förslag för autofyll. Är du säker på att du vill arkivera detta objekt?"
|
||||
},
|
||||
"upgradeToUseArchive": {
|
||||
"message": "A premium membership is required to use Archive."
|
||||
"message": "Ett premium-medlemskap krävs för att använda Arkiv."
|
||||
},
|
||||
"edit": {
|
||||
"message": "Redigera"
|
||||
@@ -598,7 +598,7 @@
|
||||
"message": "Visa alla"
|
||||
},
|
||||
"showAll": {
|
||||
"message": "Show all"
|
||||
"message": "Visa alla"
|
||||
},
|
||||
"viewLess": {
|
||||
"message": "Visa mindre"
|
||||
@@ -1874,7 +1874,7 @@
|
||||
"message": "Utgångsår"
|
||||
},
|
||||
"monthly": {
|
||||
"message": "month"
|
||||
"message": "månad"
|
||||
},
|
||||
"expiration": {
|
||||
"message": "Utgång"
|
||||
@@ -5825,10 +5825,10 @@
|
||||
"message": "Uppgradera till Premium"
|
||||
},
|
||||
"unlockAdvancedSecurity": {
|
||||
"message": "Unlock advanced security features"
|
||||
"message": "Lås upp avancerade säkerhetsfunktioner"
|
||||
},
|
||||
"unlockAdvancedSecurityDesc": {
|
||||
"message": "A Premium subscription gives you more tools to stay secure and in control"
|
||||
"message": "En Premium-prenumeration ger dig fler verktyg för att hålla dig säker och ha kontroll"
|
||||
},
|
||||
"explorePremium": {
|
||||
"message": "Utforska Premium"
|
||||
|
||||
@@ -1874,7 +1874,7 @@
|
||||
"message": "过期年份"
|
||||
},
|
||||
"monthly": {
|
||||
"message": "month"
|
||||
"message": "月"
|
||||
},
|
||||
"expiration": {
|
||||
"message": "有效期"
|
||||
@@ -4894,7 +4894,7 @@
|
||||
"message": "获取桌面 App"
|
||||
},
|
||||
"getTheDesktopAppDesc": {
|
||||
"message": "无需使用浏览器访问您的密码库,然后在桌面 App 和浏览器扩展中同时设置生物识别解锁,即可实现快速解锁。"
|
||||
"message": "无需使用浏览器访问您的密码库。在桌面 App 和浏览器扩展中同时设置生物识别解锁,即可实现快速解锁。"
|
||||
},
|
||||
"downloadFromBitwardenNow": {
|
||||
"message": "立即从 bitwarden.com 下载"
|
||||
@@ -5772,7 +5772,7 @@
|
||||
"message": "关于此设置"
|
||||
},
|
||||
"permitCipherDetailsDescription": {
|
||||
"message": "Bitwarden 将使用已保存的登录 URI 来识别应使用哪个图标或更改密码的 URL 来改善您的体验。当您使用此服务时,不会收集或保存任何信息。"
|
||||
"message": "Bitwarden 将使用已保存的登录 URI 来确定应使用的图标或更改密码的 URL,以提升您的使用体验。使用此服务时不会收集或保存任何信息。"
|
||||
},
|
||||
"noPermissionsViewPage": {
|
||||
"message": "您没有查看此页面的权限。请尝试使用其他账户登录。"
|
||||
|
||||
193
apps/browser/src/auth/popup/guards/platform-popout.guard.spec.ts
Normal file
193
apps/browser/src/auth/popup/guards/platform-popout.guard.spec.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router";
|
||||
|
||||
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||
import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
|
||||
|
||||
import { platformPopoutGuard } from "./platform-popout.guard";
|
||||
|
||||
describe("platformPopoutGuard", () => {
|
||||
let getPlatformInfoSpy: jest.SpyInstance;
|
||||
let inPopoutSpy: jest.SpyInstance;
|
||||
let inSidebarSpy: jest.SpyInstance;
|
||||
let openPopoutSpy: jest.SpyInstance;
|
||||
let closePopupSpy: jest.SpyInstance;
|
||||
|
||||
const mockRoute = {} as ActivatedRouteSnapshot;
|
||||
const mockState: RouterStateSnapshot = {
|
||||
url: "/login-with-passkey?param=value",
|
||||
} as RouterStateSnapshot;
|
||||
|
||||
beforeEach(() => {
|
||||
getPlatformInfoSpy = jest.spyOn(BrowserApi, "getPlatformInfo");
|
||||
inPopoutSpy = jest.spyOn(BrowserPopupUtils, "inPopout");
|
||||
inSidebarSpy = jest.spyOn(BrowserPopupUtils, "inSidebar");
|
||||
openPopoutSpy = jest.spyOn(BrowserPopupUtils, "openPopout").mockImplementation();
|
||||
closePopupSpy = jest.spyOn(BrowserApi, "closePopup").mockImplementation();
|
||||
|
||||
TestBed.configureTestingModule({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("when platform matches", () => {
|
||||
beforeEach(() => {
|
||||
getPlatformInfoSpy.mockResolvedValue({ os: "linux" });
|
||||
inPopoutSpy.mockReturnValue(false);
|
||||
inSidebarSpy.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("should open popout and block navigation when not already in popout or sidebar", async () => {
|
||||
const guard = platformPopoutGuard(["linux"]);
|
||||
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
|
||||
|
||||
expect(getPlatformInfoSpy).toHaveBeenCalled();
|
||||
expect(inPopoutSpy).toHaveBeenCalledWith(window);
|
||||
expect(inSidebarSpy).toHaveBeenCalledWith(window);
|
||||
expect(openPopoutSpy).toHaveBeenCalledWith(
|
||||
"popup/index.html#/login-with-passkey?param=value&autoClosePopout=true",
|
||||
);
|
||||
expect(closePopupSpy).toHaveBeenCalledWith(window);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should allow navigation when already in popout", async () => {
|
||||
inPopoutSpy.mockReturnValue(true);
|
||||
|
||||
const guard = platformPopoutGuard(["linux"]);
|
||||
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
|
||||
|
||||
expect(openPopoutSpy).not.toHaveBeenCalled();
|
||||
expect(closePopupSpy).not.toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow navigation when already in sidebar", async () => {
|
||||
inSidebarSpy.mockReturnValue(true);
|
||||
|
||||
const guard = platformPopoutGuard(["linux"]);
|
||||
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
|
||||
|
||||
expect(openPopoutSpy).not.toHaveBeenCalled();
|
||||
expect(closePopupSpy).not.toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when platform does not match", () => {
|
||||
beforeEach(() => {
|
||||
getPlatformInfoSpy.mockResolvedValue({ os: "win" });
|
||||
inPopoutSpy.mockReturnValue(false);
|
||||
inSidebarSpy.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("should allow navigation without opening popout", async () => {
|
||||
const guard = platformPopoutGuard(["linux"]);
|
||||
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
|
||||
|
||||
expect(getPlatformInfoSpy).toHaveBeenCalled();
|
||||
expect(openPopoutSpy).not.toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when forcePopout is true", () => {
|
||||
beforeEach(() => {
|
||||
getPlatformInfoSpy.mockResolvedValue({ os: "win" });
|
||||
inPopoutSpy.mockReturnValue(false);
|
||||
inSidebarSpy.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("should open popout regardless of platform", async () => {
|
||||
const guard = platformPopoutGuard(["linux"], true);
|
||||
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
|
||||
|
||||
expect(openPopoutSpy).toHaveBeenCalledWith(
|
||||
"popup/index.html#/login-with-passkey?param=value&autoClosePopout=true",
|
||||
);
|
||||
expect(closePopupSpy).toHaveBeenCalledWith(window);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should not open popout when already in popout", async () => {
|
||||
inPopoutSpy.mockReturnValue(true);
|
||||
|
||||
const guard = platformPopoutGuard(["linux"], true);
|
||||
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
|
||||
|
||||
expect(openPopoutSpy).not.toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with multiple platforms", () => {
|
||||
beforeEach(() => {
|
||||
inPopoutSpy.mockReturnValue(false);
|
||||
inSidebarSpy.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it.each(["linux", "mac", "win"])(
|
||||
"should open popout when platform is %s and included in platforms array",
|
||||
async (platform) => {
|
||||
getPlatformInfoSpy.mockResolvedValue({ os: platform });
|
||||
|
||||
const guard = platformPopoutGuard(["linux", "mac", "win"]);
|
||||
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
|
||||
|
||||
expect(openPopoutSpy).toHaveBeenCalledWith(
|
||||
"popup/index.html#/login-with-passkey?param=value&autoClosePopout=true",
|
||||
);
|
||||
expect(closePopupSpy).toHaveBeenCalledWith(window);
|
||||
expect(result).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("should not open popout when platform is not in the array", async () => {
|
||||
getPlatformInfoSpy.mockResolvedValue({ os: "android" });
|
||||
|
||||
const guard = platformPopoutGuard(["linux", "mac"]);
|
||||
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
|
||||
|
||||
expect(openPopoutSpy).not.toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("url handling", () => {
|
||||
beforeEach(() => {
|
||||
getPlatformInfoSpy.mockResolvedValue({ os: "linux" });
|
||||
inPopoutSpy.mockReturnValue(false);
|
||||
inSidebarSpy.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("should preserve query parameters in the popout url", async () => {
|
||||
const stateWithQuery: RouterStateSnapshot = {
|
||||
url: "/path?foo=bar&baz=qux",
|
||||
} as RouterStateSnapshot;
|
||||
|
||||
const guard = platformPopoutGuard(["linux"]);
|
||||
await TestBed.runInInjectionContext(() => guard(mockRoute, stateWithQuery));
|
||||
|
||||
expect(openPopoutSpy).toHaveBeenCalledWith(
|
||||
"popup/index.html#/path?foo=bar&baz=qux&autoClosePopout=true",
|
||||
);
|
||||
expect(closePopupSpy).toHaveBeenCalledWith(window);
|
||||
});
|
||||
|
||||
it("should handle urls without query parameters", async () => {
|
||||
const stateWithoutQuery: RouterStateSnapshot = {
|
||||
url: "/simple-path",
|
||||
} as RouterStateSnapshot;
|
||||
|
||||
const guard = platformPopoutGuard(["linux"]);
|
||||
await TestBed.runInInjectionContext(() => guard(mockRoute, stateWithoutQuery));
|
||||
|
||||
expect(openPopoutSpy).toHaveBeenCalledWith(
|
||||
"popup/index.html#/simple-path?autoClosePopout=true",
|
||||
);
|
||||
expect(closePopupSpy).toHaveBeenCalledWith(window);
|
||||
});
|
||||
});
|
||||
});
|
||||
46
apps/browser/src/auth/popup/guards/platform-popout.guard.ts
Normal file
46
apps/browser/src/auth/popup/guards/platform-popout.guard.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from "@angular/router";
|
||||
|
||||
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||
import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
|
||||
|
||||
/**
|
||||
* Guard that forces a popout window for specific platforms.
|
||||
* Useful when popup context would close during operations (e.g., WebAuthn on Linux).
|
||||
*
|
||||
* @param platforms - Array of platform OS strings (e.g., ["linux", "mac", "win"])
|
||||
* @param forcePopout - If true, always force popout regardless of platform (useful for testing)
|
||||
* @returns CanActivateFn that opens popout and blocks navigation if conditions met
|
||||
*/
|
||||
export function platformPopoutGuard(
|
||||
platforms: string[],
|
||||
forcePopout: boolean = false,
|
||||
): CanActivateFn {
|
||||
return async (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => {
|
||||
// Check if current platform matches
|
||||
const platformInfo = await BrowserApi.getPlatformInfo();
|
||||
const isPlatformMatch = platforms.includes(platformInfo.os);
|
||||
|
||||
// Check if already in popout/sidebar
|
||||
const inPopout = BrowserPopupUtils.inPopout(window);
|
||||
const inSidebar = BrowserPopupUtils.inSidebar(window);
|
||||
|
||||
// Open popout if conditions met
|
||||
if ((isPlatformMatch || forcePopout) && !inPopout && !inSidebar) {
|
||||
// Add autoClosePopout query param to signal the popout should close after completion
|
||||
const [path, existingQuery] = state.url.split("?");
|
||||
const params = new URLSearchParams(existingQuery || "");
|
||||
params.set("autoClosePopout", "true");
|
||||
const urlWithAutoClose = `${path}?${params.toString()}`;
|
||||
|
||||
// Open the popout window
|
||||
await BrowserPopupUtils.openPopout(`popup/index.html#${urlWithAutoClose}`);
|
||||
|
||||
// Close the original popup window
|
||||
BrowserApi.closePopup(window);
|
||||
|
||||
return false; // Block navigation - popout will reload
|
||||
}
|
||||
|
||||
return true; // Allow navigation
|
||||
};
|
||||
}
|
||||
@@ -69,8 +69,8 @@ export type FieldRect = {
|
||||
};
|
||||
|
||||
export type InlineMenuPosition = {
|
||||
button?: InlineMenuElementPosition;
|
||||
list?: InlineMenuElementPosition;
|
||||
button?: InlineMenuElementPosition | null;
|
||||
list?: InlineMenuElementPosition | null;
|
||||
};
|
||||
|
||||
export type NewLoginCipherData = {
|
||||
|
||||
@@ -627,11 +627,11 @@ export default class NotificationBackground {
|
||||
}
|
||||
|
||||
const username: string | null = data.username || null;
|
||||
const currentPassword = data.password || null;
|
||||
const newPassword = data.newPassword || null;
|
||||
const currentPasswordFieldValue = data.password || null;
|
||||
const newPasswordFieldValue = data.newPassword || null;
|
||||
|
||||
if (authStatus === AuthenticationStatus.Locked && newPassword !== null) {
|
||||
await this.pushChangePasswordToQueue(null, loginDomain, newPassword, tab, true);
|
||||
if (authStatus === AuthenticationStatus.Locked && newPasswordFieldValue !== null) {
|
||||
await this.pushChangePasswordToQueue(null, loginDomain, newPasswordFieldValue, tab, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -657,35 +657,49 @@ export default class NotificationBackground {
|
||||
const [cipher] = ciphers;
|
||||
if (
|
||||
username !== null &&
|
||||
newPassword === null &&
|
||||
cipher.login.username === normalizedUsername &&
|
||||
cipher.login.password === currentPassword
|
||||
newPasswordFieldValue === null &&
|
||||
cipher.login.username.toLowerCase() === normalizedUsername &&
|
||||
cipher.login.password === currentPasswordFieldValue
|
||||
) {
|
||||
// Assumed to be a login
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentPassword && !newPassword) {
|
||||
if (
|
||||
ciphers.length > 0 &&
|
||||
currentPasswordFieldValue?.length &&
|
||||
// Only use current password for change if no new password present.
|
||||
if (ciphers.length > 0) {
|
||||
await this.pushChangePasswordToQueue(
|
||||
ciphers.map((cipher) => cipher.id),
|
||||
loginDomain,
|
||||
currentPassword,
|
||||
tab,
|
||||
);
|
||||
return true;
|
||||
!newPasswordFieldValue
|
||||
) {
|
||||
const currentPasswordMatchesAnExistingValue = ciphers.some(
|
||||
(cipher) =>
|
||||
cipher.login?.password?.length && cipher.login.password === currentPasswordFieldValue,
|
||||
);
|
||||
|
||||
// The password entered matched a stored cipher value with
|
||||
// the same username (no change)
|
||||
if (currentPasswordMatchesAnExistingValue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.pushChangePasswordToQueue(
|
||||
ciphers.map((cipher) => cipher.id),
|
||||
loginDomain,
|
||||
currentPasswordFieldValue,
|
||||
tab,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (newPassword) {
|
||||
if (newPasswordFieldValue) {
|
||||
// Otherwise include all known ciphers.
|
||||
if (ciphers.length > 0) {
|
||||
await this.pushChangePasswordToQueue(
|
||||
ciphers.map((cipher) => cipher.id),
|
||||
loginDomain,
|
||||
newPassword,
|
||||
newPasswordFieldValue,
|
||||
tab,
|
||||
);
|
||||
|
||||
|
||||
@@ -262,11 +262,30 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
|
||||
*/
|
||||
private notificationDataIncompleteOnBeforeRequest = (tabId: number) => {
|
||||
const modifyLoginData = this.modifyLoginCipherFormData.get(tabId);
|
||||
return (
|
||||
!modifyLoginData ||
|
||||
!this.shouldAttemptNotification(modifyLoginData, NotificationTypes.Add) ||
|
||||
!this.shouldAttemptNotification(modifyLoginData, NotificationTypes.Change)
|
||||
|
||||
if (!modifyLoginData) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const shouldAttemptAddNotification = this.shouldAttemptNotification(
|
||||
modifyLoginData,
|
||||
NotificationTypes.Add,
|
||||
);
|
||||
|
||||
if (shouldAttemptAddNotification) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const shouldAttemptChangeNotification = this.shouldAttemptNotification(
|
||||
modifyLoginData,
|
||||
NotificationTypes.Change,
|
||||
);
|
||||
|
||||
if (shouldAttemptChangeNotification) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -454,15 +473,27 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
|
||||
modifyLoginData: ModifyLoginCipherFormData,
|
||||
notificationType: NotificationType,
|
||||
): boolean => {
|
||||
// Intentionally not stripping whitespace characters here as they
|
||||
// represent user entry.
|
||||
const usernameFieldHasValue = !!(modifyLoginData?.username || "").length;
|
||||
const passwordFieldHasValue = !!(modifyLoginData?.password || "").length;
|
||||
const newPasswordFieldHasValue = !!(modifyLoginData?.newPassword || "").length;
|
||||
|
||||
const canBeUserLogin = usernameFieldHasValue && passwordFieldHasValue;
|
||||
const canBePasswordUpdate = passwordFieldHasValue && newPasswordFieldHasValue;
|
||||
|
||||
switch (notificationType) {
|
||||
// `Add` case included because all forms with cached usernames (from previous
|
||||
// visits) will appear to be "password only" and otherwise trigger the new login
|
||||
// save notification.
|
||||
case NotificationTypes.Add:
|
||||
return (
|
||||
modifyLoginData?.username && !!(modifyLoginData.password || modifyLoginData.newPassword)
|
||||
);
|
||||
// Can be values for nonstored login or account creation
|
||||
return usernameFieldHasValue && (passwordFieldHasValue || newPasswordFieldHasValue);
|
||||
case NotificationTypes.Change:
|
||||
return !!(modifyLoginData.password || modifyLoginData.newPassword);
|
||||
// Can be login with nonstored login changes or account password update
|
||||
return canBeUserLogin || canBePasswordUpdate;
|
||||
case NotificationTypes.AtRiskPassword:
|
||||
return !modifyLoginData.newPassword;
|
||||
return !newPasswordFieldHasValue;
|
||||
case NotificationTypes.Unlock:
|
||||
// Unlock notifications are handled separately and do not require form data
|
||||
return false;
|
||||
|
||||
@@ -1424,11 +1424,11 @@ export class OverlayBackground implements OverlayBackgroundInterface {
|
||||
}
|
||||
|
||||
/**
|
||||
* calculates the postion and width for multi-input totp field inline menu
|
||||
* @param totpFieldArray - the totp fields used to evaluate the position of the menu
|
||||
* calculates the position and width for multi-input TOTP field inline menu
|
||||
* @param totpFieldArray - the TOTP fields used to evaluate the position of the menu
|
||||
*/
|
||||
private calculateTotpMultiInputMenuBounds(totpFieldArray: AutofillField[]) {
|
||||
// Filter the fields based on the provided totpfields
|
||||
// Filter the fields based on the provided TOTP fields
|
||||
const filteredObjects = this.allFieldData.filter((obj) =>
|
||||
totpFieldArray.some((o) => o.opid === obj.opid),
|
||||
);
|
||||
@@ -1451,8 +1451,8 @@ export class OverlayBackground implements OverlayBackgroundInterface {
|
||||
}
|
||||
|
||||
/**
|
||||
* calculates the postion for multi-input totp field inline button
|
||||
* @param totpFieldArray - the totp fields used to evaluate the position of the menu
|
||||
* calculates the position for multi-input TOTP field inline button
|
||||
* @param totpFieldArray - the TOTP fields used to evaluate the position of the menu
|
||||
*/
|
||||
private calculateTotpMultiInputButtonBounds(totpFieldArray: AutofillField[]) {
|
||||
const filteredObjects = this.allFieldData.filter((obj) =>
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { createRequire } from "module";
|
||||
import { dirname, join, resolve } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
import type { StorybookConfig } from "@storybook/web-components-webpack5";
|
||||
import type { StorybookConfig } from "@storybook/web-components-vite";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import TsconfigPathsPlugin from "tsconfig-paths-webpack-plugin";
|
||||
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
const currentDirectory = dirname(currentFile);
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
@@ -18,10 +14,8 @@ const config: StorybookConfig = {
|
||||
stories: ["../lit-stories/**/*.lit-stories.@(js|jsx|ts|tsx)", "../lit-stories/**/*.mdx"],
|
||||
addons: [
|
||||
getAbsolutePath("@storybook/addon-links"),
|
||||
getAbsolutePath("@storybook/addon-essentials"),
|
||||
getAbsolutePath("@storybook/addon-a11y"),
|
||||
getAbsolutePath("@storybook/addon-designs"),
|
||||
getAbsolutePath("@storybook/addon-interactions"),
|
||||
{
|
||||
name: "@storybook/addon-docs",
|
||||
options: {
|
||||
@@ -34,10 +28,8 @@ const config: StorybookConfig = {
|
||||
},
|
||||
],
|
||||
framework: {
|
||||
name: getAbsolutePath("@storybook/web-components-webpack5"),
|
||||
options: {
|
||||
legacyRootApi: true,
|
||||
},
|
||||
name: getAbsolutePath("@storybook/web-components-vite"),
|
||||
options: {},
|
||||
},
|
||||
core: {
|
||||
disableTelemetry: true,
|
||||
@@ -46,33 +38,12 @@ const config: StorybookConfig = {
|
||||
...existingConfig,
|
||||
FLAGS: JSON.stringify({}),
|
||||
}),
|
||||
webpackFinal: async (config) => {
|
||||
if (config.resolve) {
|
||||
config.resolve.plugins = [
|
||||
new TsconfigPathsPlugin({
|
||||
configFile: resolve(currentDirectory, "../../../../../tsconfig.json"),
|
||||
}),
|
||||
] as any;
|
||||
}
|
||||
|
||||
if (config.module && config.module.rules) {
|
||||
config.module.rules.push({
|
||||
test: /\.(ts|tsx)$/,
|
||||
exclude: /node_modules/,
|
||||
use: [
|
||||
{
|
||||
loader: require.resolve("ts-loader"),
|
||||
},
|
||||
],
|
||||
});
|
||||
config.module.rules.push({
|
||||
test: /\.scss$/,
|
||||
use: [require.resolve("css-loader"), require.resolve("sass-loader")],
|
||||
});
|
||||
}
|
||||
return config;
|
||||
viteFinal: async (config) => {
|
||||
return {
|
||||
...config,
|
||||
plugins: [...(config.plugins ?? []), tsconfigPaths()],
|
||||
};
|
||||
},
|
||||
docs: {},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Controls, Primary } from "@storybook/addon-docs";
|
||||
import { Meta, Controls, Primary } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import * as stories from "./action-button.lit-stories";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Controls, Primary } from "@storybook/addon-docs";
|
||||
import { Meta, Controls, Primary } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import * as stories from "./badge-button.lit-stories";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Controls, Primary } from "@storybook/addon-docs";
|
||||
import { Meta, Controls, Primary } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import * as stories from "./body.lit-stories";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Controls, Primary } from "@storybook/addon-docs";
|
||||
import { Meta, Controls, Primary } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import * as stories from "./close-button.lit-stories";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Controls, Primary } from "@storybook/addon-docs";
|
||||
import { Meta, Controls, Primary } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import * as stories from "./edit-button.lit-stories";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Controls, Primary } from "@storybook/addon-docs";
|
||||
import { Meta, Controls, Primary } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import * as stories from "./footer.lit-stories";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Controls, Primary } from "@storybook/addon-docs";
|
||||
import { Meta, Controls, Primary } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import * as stories from "./header.lit-stories";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Controls } from "@storybook/addon-docs";
|
||||
import { Meta, Controls } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import * as stories from "./icons.lit-stories";
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ type SharedFido2ScriptRegistrationOptions = SharedFido2ScriptInjectionDetails &
|
||||
matches: string[];
|
||||
excludeMatches: string[];
|
||||
allFrames: true;
|
||||
world?: "MAIN" | "ISOLATED";
|
||||
};
|
||||
|
||||
type Fido2ExtensionMessage = {
|
||||
|
||||
@@ -203,7 +203,6 @@ describe("Fido2Background", () => {
|
||||
{ file: Fido2ContentScript.PageScriptDelayAppend },
|
||||
{ file: Fido2ContentScript.ContentScript },
|
||||
],
|
||||
world: "MAIN",
|
||||
...sharedRegistrationOptions,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,7 +176,6 @@ export class Fido2Background implements Fido2BackgroundInterface {
|
||||
{ file: await this.getFido2PageScriptAppendFileName() },
|
||||
{ file: Fido2ContentScript.ContentScript },
|
||||
],
|
||||
world: "MAIN",
|
||||
...this.sharedRegistrationOptions,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
}
|
||||
|
||||
const script = globalContext.document.createElement("script");
|
||||
// This script runs in world: MAIN, eliminating the risk associated with this lint error.
|
||||
// DOM injection is still needed for the iframe timing hack.
|
||||
// We're removing stack trace information in the page script instead
|
||||
// eslint-disable-next-line @bitwarden/platform/no-page-script-url-leakage
|
||||
script.src = chrome.runtime.getURL("content/fido2-page-script.js");
|
||||
script.async = false;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { EVENTS } from "@bitwarden/common/autofill/constants";
|
||||
import { ThemeType } from "@bitwarden/common/platform/enums";
|
||||
import { ThemeTypes } from "@bitwarden/common/platform/enums";
|
||||
|
||||
import { AutofillOverlayPort } from "../../../enums/autofill-overlay.enum";
|
||||
import { createPortSpyMock } from "../../../spec/autofill-mocks";
|
||||
@@ -66,17 +66,38 @@ describe("AutofillInlineMenuIframeService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// TODO CG - This test is brittle and failing due to how we are calling the private method. This needs to be reworked
|
||||
it.skip("creates an aria alert element if the ariaAlert param is passed", () => {
|
||||
const ariaAlert = "aria alert";
|
||||
it("creates an aria alert element if the ariaAlert param is passed to AutofillInlineMenuIframeService", () => {
|
||||
jest.spyOn(autofillInlineMenuIframeService as any, "createAriaAlertElement");
|
||||
|
||||
autofillInlineMenuIframeService.initMenuIframe();
|
||||
|
||||
expect(autofillInlineMenuIframeService["createAriaAlertElement"]).toHaveBeenCalledWith(
|
||||
ariaAlert,
|
||||
expect(autofillInlineMenuIframeService["createAriaAlertElement"]).toHaveBeenCalled();
|
||||
expect(autofillInlineMenuIframeService["ariaAlertElement"]).toBeDefined();
|
||||
expect(autofillInlineMenuIframeService["ariaAlertElement"].getAttribute("role")).toBe(
|
||||
"alert",
|
||||
);
|
||||
expect(autofillInlineMenuIframeService["ariaAlertElement"]).toMatchSnapshot();
|
||||
expect(autofillInlineMenuIframeService["ariaAlertElement"].getAttribute("aria-live")).toBe(
|
||||
"polite",
|
||||
);
|
||||
expect(autofillInlineMenuIframeService["ariaAlertElement"].getAttribute("aria-atomic")).toBe(
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not create an aria alert element if the ariaAlert param is not passed to AutofillInlineMenuIframeService", () => {
|
||||
const shadowWithoutAlert = document.createElement("div").attachShadow({ mode: "open" });
|
||||
const serviceWithoutAlert = new AutofillInlineMenuIframeService(
|
||||
shadowWithoutAlert,
|
||||
AutofillOverlayPort.Button,
|
||||
{ height: "0px" },
|
||||
"title",
|
||||
);
|
||||
jest.spyOn(serviceWithoutAlert as any, "createAriaAlertElement");
|
||||
|
||||
serviceWithoutAlert.initMenuIframe();
|
||||
|
||||
expect(serviceWithoutAlert["createAriaAlertElement"]).not.toHaveBeenCalled();
|
||||
expect(serviceWithoutAlert["ariaAlertElement"]).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("on load of the iframe source", () => {
|
||||
@@ -200,7 +221,7 @@ describe("AutofillInlineMenuIframeService", () => {
|
||||
sendPortMessage(portSpy, { command: "updateAutofillInlineMenuPosition" });
|
||||
|
||||
expect(
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -216,7 +237,7 @@ describe("AutofillInlineMenuIframeService", () => {
|
||||
|
||||
expect(autofillInlineMenuIframeService["portKey"]).toBe(portKey);
|
||||
expect(
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage,
|
||||
).toHaveBeenCalledWith(message, autofillInlineMenuIframeService["extensionOrigin"]);
|
||||
});
|
||||
});
|
||||
@@ -234,14 +255,14 @@ describe("AutofillInlineMenuIframeService", () => {
|
||||
it("passes the message on to the iframe element", () => {
|
||||
const message = {
|
||||
command: "initAutofillInlineMenuList",
|
||||
theme: ThemeType.Light,
|
||||
theme: ThemeTypes.Light,
|
||||
};
|
||||
|
||||
sendPortMessage(portSpy, message);
|
||||
|
||||
expect(updateElementStylesSpy).not.toHaveBeenCalled();
|
||||
expect(
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage,
|
||||
).toHaveBeenCalledWith(message, autofillInlineMenuIframeService["extensionOrigin"]);
|
||||
});
|
||||
|
||||
@@ -249,18 +270,18 @@ describe("AutofillInlineMenuIframeService", () => {
|
||||
window.matchMedia = jest.fn(() => mock<MediaQueryList>({ matches: false }));
|
||||
const message = {
|
||||
command: "initAutofillInlineMenuList",
|
||||
theme: ThemeType.System,
|
||||
theme: ThemeTypes.System,
|
||||
};
|
||||
|
||||
sendPortMessage(portSpy, message);
|
||||
|
||||
expect(window.matchMedia).toHaveBeenCalledWith("(prefers-color-scheme: dark)");
|
||||
expect(
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage,
|
||||
).toHaveBeenCalledWith(
|
||||
{
|
||||
command: "initAutofillInlineMenuList",
|
||||
theme: ThemeType.Light,
|
||||
theme: ThemeTypes.Light,
|
||||
},
|
||||
autofillInlineMenuIframeService["extensionOrigin"],
|
||||
);
|
||||
@@ -270,18 +291,18 @@ describe("AutofillInlineMenuIframeService", () => {
|
||||
window.matchMedia = jest.fn(() => mock<MediaQueryList>({ matches: true }));
|
||||
const message = {
|
||||
command: "initAutofillInlineMenuList",
|
||||
theme: ThemeType.System,
|
||||
theme: ThemeTypes.System,
|
||||
};
|
||||
|
||||
sendPortMessage(portSpy, message);
|
||||
|
||||
expect(window.matchMedia).toHaveBeenCalledWith("(prefers-color-scheme: dark)");
|
||||
expect(
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage,
|
||||
).toHaveBeenCalledWith(
|
||||
{
|
||||
command: "initAutofillInlineMenuList",
|
||||
theme: ThemeType.Dark,
|
||||
theme: ThemeTypes.Dark,
|
||||
},
|
||||
autofillInlineMenuIframeService["extensionOrigin"],
|
||||
);
|
||||
@@ -290,7 +311,7 @@ describe("AutofillInlineMenuIframeService", () => {
|
||||
it("updates the border to match the `dark` theme", () => {
|
||||
const message = {
|
||||
command: "initAutofillInlineMenuList",
|
||||
theme: ThemeType.Dark,
|
||||
theme: ThemeTypes.Dark,
|
||||
};
|
||||
|
||||
sendPortMessage(portSpy, message);
|
||||
@@ -364,6 +385,219 @@ describe("AutofillInlineMenuIframeService", () => {
|
||||
autofillInlineMenuIframeService["handleFadeInInlineMenuIframe"],
|
||||
).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes the inline menu when iframe is outside the viewport (bottom)", () => {
|
||||
const viewportHeight = 800;
|
||||
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
|
||||
jest
|
||||
.spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect")
|
||||
.mockReturnValue({
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 100,
|
||||
bottom: viewportHeight + 1,
|
||||
height: 98,
|
||||
width: 262,
|
||||
} as DOMRect);
|
||||
Object.defineProperty(globalThis.window, "innerHeight", {
|
||||
value: viewportHeight,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis.window, "innerWidth", {
|
||||
value: 1200,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
sendPortMessage(portSpy, {
|
||||
command: "updateAutofillInlineMenuPosition",
|
||||
styles: {},
|
||||
});
|
||||
|
||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", {
|
||||
forceCloseInlineMenu: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("closes the inline menu when iframe is outside the viewport (right)", () => {
|
||||
const viewportWidth = 1200;
|
||||
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
|
||||
jest
|
||||
.spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect")
|
||||
.mockReturnValue({
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: viewportWidth + 1,
|
||||
bottom: 100,
|
||||
height: 98,
|
||||
width: 262,
|
||||
} as DOMRect);
|
||||
Object.defineProperty(globalThis.window, "innerHeight", {
|
||||
value: 800,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis.window, "innerWidth", {
|
||||
value: viewportWidth,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
sendPortMessage(portSpy, {
|
||||
command: "updateAutofillInlineMenuPosition",
|
||||
styles: {},
|
||||
});
|
||||
|
||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", {
|
||||
forceCloseInlineMenu: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("closes the inline menu when iframe is outside the viewport (left)", () => {
|
||||
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
|
||||
jest
|
||||
.spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect")
|
||||
.mockReturnValue({
|
||||
top: 0,
|
||||
left: -1,
|
||||
right: 0,
|
||||
bottom: 100,
|
||||
height: 98,
|
||||
width: 262,
|
||||
} as DOMRect);
|
||||
Object.defineProperty(globalThis.window, "innerHeight", {
|
||||
value: 800,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis.window, "innerWidth", {
|
||||
value: 1200,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
sendPortMessage(portSpy, {
|
||||
command: "updateAutofillInlineMenuPosition",
|
||||
styles: {},
|
||||
});
|
||||
|
||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", {
|
||||
forceCloseInlineMenu: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("closes the inline menu when iframe is outside the viewport (top)", () => {
|
||||
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
|
||||
jest
|
||||
.spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect")
|
||||
.mockReturnValue({
|
||||
top: -1,
|
||||
left: 0,
|
||||
right: 100,
|
||||
bottom: 0,
|
||||
height: 98,
|
||||
width: 262,
|
||||
} as DOMRect);
|
||||
Object.defineProperty(globalThis.window, "innerHeight", {
|
||||
value: 800,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis.window, "innerWidth", {
|
||||
value: 1200,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
sendPortMessage(portSpy, {
|
||||
command: "updateAutofillInlineMenuPosition",
|
||||
styles: {},
|
||||
});
|
||||
|
||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", {
|
||||
forceCloseInlineMenu: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows iframe (do not close) when it has no dimensions", () => {
|
||||
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
|
||||
jest
|
||||
.spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect")
|
||||
.mockReturnValue({
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
width: 0,
|
||||
} as DOMRect);
|
||||
|
||||
Object.defineProperty(globalThis.window, "innerHeight", {
|
||||
value: 800,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis.window, "innerWidth", {
|
||||
value: 1200,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
sendPortMessage(portSpy, {
|
||||
command: "updateAutofillInlineMenuPosition",
|
||||
styles: {},
|
||||
});
|
||||
|
||||
expect(sendExtensionMessageSpy).not.toHaveBeenCalledWith("closeAutofillInlineMenu", {
|
||||
forceCloseInlineMenu: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses visualViewport when available", () => {
|
||||
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
|
||||
jest
|
||||
.spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect")
|
||||
.mockReturnValue({
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 100,
|
||||
bottom: 700,
|
||||
height: 98,
|
||||
width: 262,
|
||||
} as DOMRect);
|
||||
|
||||
Object.defineProperty(globalThis.window, "visualViewport", {
|
||||
value: {
|
||||
height: 600,
|
||||
width: 1200,
|
||||
} as VisualViewport,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis.window, "innerHeight", {
|
||||
value: 800,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis.window, "innerWidth", {
|
||||
value: 1200,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
sendPortMessage(portSpy, {
|
||||
command: "updateAutofillInlineMenuPosition",
|
||||
styles: {},
|
||||
});
|
||||
|
||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", {
|
||||
forceCloseInlineMenu: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("updates the visibility of the iframe", () => {
|
||||
@@ -381,7 +615,7 @@ describe("AutofillInlineMenuIframeService", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage,
|
||||
).toHaveBeenCalledWith(
|
||||
{
|
||||
command: "updateAutofillInlineMenuColorScheme",
|
||||
|
||||
@@ -282,6 +282,15 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
|
||||
const styles = this.fadeInTimeout ? Object.assign(position, { opacity: "0" }) : position;
|
||||
this.updateElementStyles(this.iframe, styles);
|
||||
|
||||
const elementHeightCompletelyInViewport = this.isElementCompletelyWithinViewport(
|
||||
this.iframe.getBoundingClientRect(),
|
||||
);
|
||||
|
||||
if (!elementHeightCompletelyInViewport) {
|
||||
this.forceCloseInlineMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.fadeInTimeout) {
|
||||
this.handleFadeInInlineMenuIframe();
|
||||
}
|
||||
@@ -289,6 +298,42 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
|
||||
this.announceAriaAlert(this.ariaAlert, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if element is completely within the browser viewport.
|
||||
*/
|
||||
private isElementCompletelyWithinViewport(elementPosition: DOMRect) {
|
||||
// An element that lacks size should be considered within the viewport
|
||||
if (!elementPosition.height || !elementPosition.width) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [viewportHeight, viewportWidth] = this.getViewportSize();
|
||||
|
||||
const rightSideIsWithinViewport = (elementPosition.right || 0) <= viewportWidth;
|
||||
const leftSideIsWithinViewport = (elementPosition.left || 0) >= 0;
|
||||
const topSideIsWithinViewport = (elementPosition.top || 0) >= 0;
|
||||
const bottomSideIsWithinViewport = (elementPosition.bottom || 0) <= viewportHeight;
|
||||
|
||||
return (
|
||||
rightSideIsWithinViewport &&
|
||||
leftSideIsWithinViewport &&
|
||||
topSideIsWithinViewport &&
|
||||
bottomSideIsWithinViewport
|
||||
);
|
||||
}
|
||||
|
||||
/** Use Visual Viewport API if available (better for mobile/zoom) */
|
||||
private getViewportSize(): [
|
||||
VisualViewport["height"] | Window["innerHeight"],
|
||||
VisualViewport["width"] | Window["innerWidth"],
|
||||
] {
|
||||
if ("visualViewport" in globalThis.window && globalThis.window.visualViewport) {
|
||||
return [globalThis.window.visualViewport.height, globalThis.window.visualViewport.width];
|
||||
}
|
||||
|
||||
return [globalThis.window.innerHeight, globalThis.window.innerWidth];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -39,6 +39,7 @@ export class AutoFillConstants {
|
||||
"otpcode",
|
||||
"onetimepassword",
|
||||
"security_code",
|
||||
"second-factor",
|
||||
"twofactor",
|
||||
"twofa",
|
||||
"twofactorcode",
|
||||
|
||||
@@ -1603,14 +1603,14 @@ describe("AutofillOverlayContentService", () => {
|
||||
|
||||
it("skips triggering submission if a button is not found", async () => {
|
||||
const submitButton = document.querySelector("button");
|
||||
submitButton.remove();
|
||||
submitButton?.remove();
|
||||
|
||||
await autofillOverlayContentService.setupOverlayListeners(
|
||||
autofillFieldElement,
|
||||
autofillFieldData,
|
||||
pageDetailsMock,
|
||||
);
|
||||
submitButton.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
|
||||
submitButton?.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
|
||||
|
||||
expect(sendExtensionMessageSpy).not.toHaveBeenCalledWith(
|
||||
"formFieldSubmitted",
|
||||
@@ -1627,7 +1627,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
pageDetailsMock,
|
||||
);
|
||||
await flushPromises();
|
||||
submitButton.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
|
||||
submitButton?.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
|
||||
|
||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith(
|
||||
"formFieldSubmitted",
|
||||
@@ -1641,7 +1641,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
<div id="shadow-root"></div>
|
||||
<button id="button-el">Change Password</button>
|
||||
</div>`;
|
||||
const shadowRoot = document.getElementById("shadow-root").attachShadow({ mode: "open" });
|
||||
const shadowRoot = document.getElementById("shadow-root")!.attachShadow({ mode: "open" });
|
||||
shadowRoot.innerHTML = `
|
||||
<input type="password" id="password-field-1" placeholder="new password" />
|
||||
`;
|
||||
@@ -1668,7 +1668,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
pageDetailsMock,
|
||||
);
|
||||
await flushPromises();
|
||||
buttonElement.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
|
||||
buttonElement?.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
|
||||
|
||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith(
|
||||
"formFieldSubmitted",
|
||||
@@ -1716,6 +1716,85 @@ describe("AutofillOverlayContentService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshMenuLayerPosition", () => {
|
||||
it("calls refreshTopLayerPosition on the inline menu content service", () => {
|
||||
autofillOverlayContentService.refreshMenuLayerPosition();
|
||||
|
||||
expect(inlineMenuContentService.refreshTopLayerPosition).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not throw if inline menu content service is not available", () => {
|
||||
const serviceWithoutInlineMenu = new AutofillOverlayContentService(
|
||||
domQueryService,
|
||||
domElementVisibilityService,
|
||||
inlineMenuFieldQualificationService,
|
||||
);
|
||||
|
||||
expect(() => serviceWithoutInlineMenu.refreshMenuLayerPosition()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOwnedInlineMenuTagNames", () => {
|
||||
it("returns tag names from the inline menu content service", () => {
|
||||
inlineMenuContentService.getOwnedTagNames.mockReturnValue(["div", "span"]);
|
||||
|
||||
const result = autofillOverlayContentService.getOwnedInlineMenuTagNames();
|
||||
|
||||
expect(result).toEqual(["div", "span"]);
|
||||
});
|
||||
|
||||
it("returns an empty array if inline menu content service is not available", () => {
|
||||
const serviceWithoutInlineMenu = new AutofillOverlayContentService(
|
||||
domQueryService,
|
||||
domElementVisibilityService,
|
||||
inlineMenuFieldQualificationService,
|
||||
);
|
||||
|
||||
const result = serviceWithoutInlineMenu.getOwnedInlineMenuTagNames();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUnownedTopLayerItems", () => {
|
||||
it("returns unowned top layer items from the inline menu content service", () => {
|
||||
const mockElements = document.querySelectorAll("div");
|
||||
inlineMenuContentService.getUnownedTopLayerItems.mockReturnValue(mockElements);
|
||||
|
||||
const result = autofillOverlayContentService.getUnownedTopLayerItems(true);
|
||||
|
||||
expect(result).toEqual(mockElements);
|
||||
expect(inlineMenuContentService.getUnownedTopLayerItems).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("returns undefined if inline menu content service is not available", () => {
|
||||
const serviceWithoutInlineMenu = new AutofillOverlayContentService(
|
||||
domQueryService,
|
||||
domElementVisibilityService,
|
||||
inlineMenuFieldQualificationService,
|
||||
);
|
||||
|
||||
const result = serviceWithoutInlineMenu.getUnownedTopLayerItems();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearUserFilledFields", () => {
|
||||
it("deletes all user filled fields", () => {
|
||||
const mockElement1 = document.createElement("input") as FillableFormFieldElement;
|
||||
const mockElement2 = document.createElement("input") as FillableFormFieldElement;
|
||||
autofillOverlayContentService["userFilledFields"] = {
|
||||
username: mockElement1,
|
||||
password: mockElement2,
|
||||
};
|
||||
|
||||
autofillOverlayContentService.clearUserFilledFields();
|
||||
|
||||
expect(autofillOverlayContentService["userFilledFields"]).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleOverlayRepositionEvent", () => {
|
||||
const repositionEvents = [EVENTS.SCROLL, EVENTS.RESIZE];
|
||||
repositionEvents.forEach((repositionEvent) => {
|
||||
@@ -2049,7 +2128,7 @@ describe("AutofillOverlayContentService", () => {
|
||||
});
|
||||
|
||||
it("skips focusing an element if no recently focused field exists", async () => {
|
||||
autofillOverlayContentService["mostRecentlyFocusedField"] = undefined;
|
||||
(autofillOverlayContentService as any)["mostRecentlyFocusedField"] = null;
|
||||
|
||||
sendMockExtensionMessage({
|
||||
command: "redirectAutofillInlineMenuFocusOut",
|
||||
@@ -2149,7 +2228,6 @@ describe("AutofillOverlayContentService", () => {
|
||||
});
|
||||
|
||||
it("returns null if the sub frame URL cannot be parsed correctly", async () => {
|
||||
delete globalThis.location;
|
||||
globalThis.location = { href: "invalid-base" } as Location;
|
||||
sendMockExtensionMessage(
|
||||
{
|
||||
|
||||
@@ -1400,7 +1400,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
this.intersectionObserver = new IntersectionObserver(this.handleFormElementIntersection, {
|
||||
root: null,
|
||||
rootMargin: "0px",
|
||||
threshold: 1.0,
|
||||
threshold: 0.9999, // Safari doesn't seem to function properly with a threshold of 1,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -945,7 +945,8 @@ export class InlineMenuFieldQualificationService
|
||||
!fieldType ||
|
||||
!this.usernameFieldTypes.has(fieldType) ||
|
||||
this.isExcludedFieldType(field, this.excludedAutofillFieldTypesSet) ||
|
||||
this.fieldHasDisqualifyingAttributeValue(field)
|
||||
this.fieldHasDisqualifyingAttributeValue(field) ||
|
||||
this.isTotpField(field)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -732,7 +732,11 @@ export default class MainBackground {
|
||||
this.singleUserStateProvider,
|
||||
);
|
||||
this.organizationService = new DefaultOrganizationService(this.stateProvider);
|
||||
this.policyService = new DefaultPolicyService(this.stateProvider, this.organizationService);
|
||||
this.policyService = new DefaultPolicyService(
|
||||
this.stateProvider,
|
||||
this.organizationService,
|
||||
this.accountService,
|
||||
);
|
||||
|
||||
this.vaultTimeoutSettingsService = new DefaultVaultTimeoutSettingsService(
|
||||
this.accountService,
|
||||
@@ -1196,6 +1200,7 @@ export default class MainBackground {
|
||||
this.webPushConnectionService,
|
||||
this.authRequestAnsweringService,
|
||||
this.configService,
|
||||
this.policyService,
|
||||
);
|
||||
|
||||
this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div class="tw-flex tw-flex-col tw-p-2">
|
||||
<ul class="tw-list-disc tw-pl-5 tw-space-y-2 tw-break-words tw-mb-0">
|
||||
<li>
|
||||
{{ "ppremiumSignUpStorage" | i18n }}
|
||||
{{ "premiumSignUpStorageV2" | i18n: `${storageProvidedGb} GB` }}
|
||||
</li>
|
||||
<li>
|
||||
{{ "premiumSignUpTwoStepOptions" | i18n }}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule, CurrencyPipe, Location } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { RouterModule } from "@angular/router";
|
||||
|
||||
import { PremiumComponent as BasePremiumComponent } from "@bitwarden/angular/billing/components/premium.component";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -44,7 +45,7 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co
|
||||
SectionComponent,
|
||||
],
|
||||
})
|
||||
export class PremiumV2Component extends BasePremiumComponent {
|
||||
export class PremiumV2Component extends BasePremiumComponent implements OnInit {
|
||||
priceString: string;
|
||||
|
||||
constructor(
|
||||
@@ -59,6 +60,7 @@ export class PremiumV2Component extends BasePremiumComponent {
|
||||
billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
toastService: ToastService,
|
||||
accountService: AccountService,
|
||||
billingApiService: BillingApiServiceAbstraction,
|
||||
) {
|
||||
super(
|
||||
i18nService,
|
||||
@@ -70,15 +72,18 @@ export class PremiumV2Component extends BasePremiumComponent {
|
||||
billingAccountProfileStateService,
|
||||
toastService,
|
||||
accountService,
|
||||
billingApiService,
|
||||
);
|
||||
|
||||
}
|
||||
async ngOnInit() {
|
||||
await super.ngOnInit();
|
||||
// Support old price string. Can be removed in future once all translations are properly updated.
|
||||
const thePrice = this.currencyPipe.transform(this.price, "$");
|
||||
// Safari extension crashes due to $1 appearing in the price string ($10.00). Escape the $ to fix.
|
||||
const formattedPrice = this.platformUtilsService.isSafari()
|
||||
? thePrice.replace("$", "$$$")
|
||||
: thePrice;
|
||||
this.priceString = i18nService.t("premiumPriceV2", formattedPrice);
|
||||
this.priceString = this.i18nService.t("premiumPriceV2", formattedPrice);
|
||||
if (this.priceString.indexOf("%price%") > -1) {
|
||||
this.priceString = this.priceString.replace("%price%", thePrice);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 2,
|
||||
"name": "__MSG_extName__",
|
||||
"short_name": "Bitwarden",
|
||||
"version": "2025.11.2",
|
||||
"version": "2025.12.0",
|
||||
"description": "__MSG_extDesc__",
|
||||
"default_locale": "en",
|
||||
"author": "Bitwarden Inc.",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"minimum_chrome_version": "102.0",
|
||||
"name": "__MSG_extName__",
|
||||
"short_name": "Bitwarden",
|
||||
"version": "2025.11.2",
|
||||
"version": "2025.12.0",
|
||||
"description": "__MSG_extDesc__",
|
||||
"default_locale": "en",
|
||||
"author": "Bitwarden Inc.",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Story, Canvas } from "@storybook/addon-docs";
|
||||
import { Meta, Story, Canvas } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import * as stories from "./popup-layout.stories";
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/ke
|
||||
import { AccountSwitcherComponent } from "../auth/popup/account-switching/account-switcher.component";
|
||||
import { AuthExtensionRoute } from "../auth/popup/constants/auth-extension-route.constant";
|
||||
import { fido2AuthGuard } from "../auth/popup/guards/fido2-auth.guard";
|
||||
import { platformPopoutGuard } from "../auth/popup/guards/platform-popout.guard";
|
||||
import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component";
|
||||
import { ExtensionDeviceManagementComponent } from "../auth/popup/settings/extension-device-management.component";
|
||||
import { Fido2Component } from "../autofill/popup/fido2/fido2.component";
|
||||
@@ -414,7 +415,7 @@ const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: AuthRoute.LoginWithPasskey,
|
||||
canActivate: [unauthGuardFn(unauthRouteOverrides)],
|
||||
canActivate: [unauthGuardFn(unauthRouteOverrides), platformPopoutGuard(["linux"])],
|
||||
data: {
|
||||
pageIcon: TwoFactorAuthSecurityKeyIcon,
|
||||
pageTitle: {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { DOCUMENT } from "@angular/common";
|
||||
import { inject, Inject, Injectable } from "@angular/core";
|
||||
import { inject, Inject, Injectable, DOCUMENT } from "@angular/core";
|
||||
|
||||
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
|
||||
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
|
||||
|
||||
@@ -381,4 +381,88 @@ describe("AddEditV2Component", () => {
|
||||
expect(navigate).toHaveBeenCalledWith(["/tabs/vault"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reloadAddEditCipherData", () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
addEditCipherInfo$.next({
|
||||
cipher: {
|
||||
name: "InitialName",
|
||||
type: CipherType.Login,
|
||||
login: {
|
||||
password: "initialPassword",
|
||||
username: "initialUsername",
|
||||
uris: [{ uri: "https://initial.com" }],
|
||||
},
|
||||
},
|
||||
} as AddEditCipherInfo);
|
||||
queryParams$.next({});
|
||||
tick();
|
||||
|
||||
cipherServiceMock.setAddEditCipherInfo.mockClear();
|
||||
}));
|
||||
|
||||
it("replaces all initialValues with new data, clearing stale fields", fakeAsync(() => {
|
||||
const newCipherInfo = {
|
||||
cipher: {
|
||||
name: "UpdatedName",
|
||||
type: CipherType.Login,
|
||||
login: {
|
||||
password: "updatedPassword",
|
||||
uris: [{ uri: "https://updated.com" }],
|
||||
},
|
||||
},
|
||||
} as AddEditCipherInfo;
|
||||
|
||||
addEditCipherInfo$.next(newCipherInfo);
|
||||
|
||||
const messageListener = component["messageListener"];
|
||||
messageListener({ command: "reloadAddEditCipherData" });
|
||||
tick();
|
||||
|
||||
expect(component.config.initialValues).toEqual({
|
||||
name: "UpdatedName",
|
||||
password: "updatedPassword",
|
||||
loginUri: "https://updated.com",
|
||||
} as OptionalInitialValues);
|
||||
|
||||
expect(cipherServiceMock.setAddEditCipherInfo).toHaveBeenCalledWith(null, "UserId");
|
||||
}));
|
||||
|
||||
it("does not reload data if config is not set", fakeAsync(() => {
|
||||
component.config = null;
|
||||
|
||||
const messageListener = component["messageListener"];
|
||||
messageListener({ command: "reloadAddEditCipherData" });
|
||||
tick();
|
||||
|
||||
expect(cipherServiceMock.setAddEditCipherInfo).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it("does not reload data if latestCipherInfo is null", fakeAsync(() => {
|
||||
addEditCipherInfo$.next(null);
|
||||
|
||||
const messageListener = component["messageListener"];
|
||||
messageListener({ command: "reloadAddEditCipherData" });
|
||||
tick();
|
||||
|
||||
expect(component.config.initialValues).toEqual({
|
||||
name: "InitialName",
|
||||
password: "initialPassword",
|
||||
username: "initialUsername",
|
||||
loginUri: "https://initial.com",
|
||||
} as OptionalInitialValues);
|
||||
|
||||
expect(cipherServiceMock.setAddEditCipherInfo).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it("ignores messages with different commands", fakeAsync(() => {
|
||||
const initialValues = component.config.initialValues;
|
||||
|
||||
const messageListener = component["messageListener"];
|
||||
messageListener({ command: "someOtherCommand" });
|
||||
tick();
|
||||
|
||||
expect(component.config.initialValues).toBe(initialValues);
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { Component, OnInit, OnDestroy } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormsModule } from "@angular/forms";
|
||||
import { ActivatedRoute, Params, Router } from "@angular/router";
|
||||
@@ -158,7 +158,7 @@ export type AddEditQueryParams = Partial<Record<keyof QueryParams, string>>;
|
||||
IconButtonModule,
|
||||
],
|
||||
})
|
||||
export class AddEditV2Component implements OnInit {
|
||||
export class AddEditV2Component implements OnInit, OnDestroy {
|
||||
headerText: string;
|
||||
config: CipherFormConfig;
|
||||
canDeleteCipher$: Observable<boolean>;
|
||||
@@ -200,12 +200,58 @@ export class AddEditV2Component implements OnInit {
|
||||
this.subscribeToParams();
|
||||
}
|
||||
|
||||
private messageListener: (message: any) => void;
|
||||
|
||||
async ngOnInit() {
|
||||
this.fido2PopoutSessionData = await firstValueFrom(this.fido2PopoutSessionData$);
|
||||
|
||||
if (BrowserPopupUtils.inPopout(window)) {
|
||||
this.popupCloseWarningService.enable();
|
||||
}
|
||||
|
||||
// Listen for messages to reload cipher data when the pop up is already open
|
||||
this.messageListener = async (message: any) => {
|
||||
if (message?.command === "reloadAddEditCipherData") {
|
||||
try {
|
||||
await this.reloadCipherData();
|
||||
} catch (error) {
|
||||
this.logService.error("Failed to reload cipher data", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
BrowserApi.addListener(chrome.runtime.onMessage, this.messageListener);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
if (this.messageListener) {
|
||||
BrowserApi.removeListener(chrome.runtime.onMessage, this.messageListener);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads the cipher data when the popup is already open and new form data is submitted.
|
||||
* This completely replaces the initialValues to clear any stale data from the previous submission.
|
||||
*/
|
||||
private async reloadCipherData() {
|
||||
if (!this.config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
|
||||
const latestCipherInfo = await firstValueFrom(
|
||||
this.cipherService.addEditCipherInfo$(activeUserId),
|
||||
);
|
||||
|
||||
if (latestCipherInfo != null) {
|
||||
this.config = {
|
||||
...this.config,
|
||||
initialValues: mapAddEditCipherInfoToInitialValues(latestCipherInfo),
|
||||
};
|
||||
|
||||
// Be sure to clear the "cached" cipher info, so it doesn't get used again
|
||||
await this.cipherService.setAddEditCipherInfo(null, activeUserId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
CipherViewLike,
|
||||
CipherViewLikeUtils,
|
||||
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
import { IconButtonModule, ItemModule, MenuModule } from "@bitwarden/components";
|
||||
import { CipherListView, CopyableCipherFields } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { VaultPopupCopyButtonsService } from "../../../services/vault-popup-copy-buttons.service";
|
||||
|
||||
import { ItemCopyActionsComponent } from "./item-copy-actions.component";
|
||||
|
||||
describe("ItemCopyActionsComponent", () => {
|
||||
let fixture: ComponentFixture<ItemCopyActionsComponent>;
|
||||
let component: ItemCopyActionsComponent;
|
||||
|
||||
let i18nService: jest.Mocked<I18nService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
i18nService = {
|
||||
t: jest.fn((key: string) => `translated-${key}`),
|
||||
} as unknown as jest.Mocked<I18nService>;
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
JslibModule,
|
||||
ItemModule,
|
||||
IconButtonModule,
|
||||
MenuModule,
|
||||
ItemCopyActionsComponent, // standalone
|
||||
],
|
||||
providers: [
|
||||
{ provide: I18nService, useValue: i18nService },
|
||||
{
|
||||
provide: VaultPopupCopyButtonsService,
|
||||
useValue: {
|
||||
showQuickCopyActions$: of(true),
|
||||
} satisfies Partial<VaultPopupCopyButtonsService>,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ItemCopyActionsComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
// Default cipher so tests can override as needed
|
||||
component.cipher = {
|
||||
name: "My cipher",
|
||||
viewPassword: true,
|
||||
login: { username: null, password: null, totp: null },
|
||||
card: { code: null, number: null },
|
||||
identity: {
|
||||
fullAddressForCopy: null,
|
||||
email: null,
|
||||
username: null,
|
||||
phone: null,
|
||||
},
|
||||
sshKey: {
|
||||
privateKey: null,
|
||||
publicKey: null,
|
||||
keyFingerprint: null,
|
||||
},
|
||||
notes: null,
|
||||
copyableFields: [],
|
||||
} as unknown as CipherViewLike;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("findSingleCopyableItem", () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(CipherViewLikeUtils, "hasCopyableValue")
|
||||
.mockImplementation(
|
||||
(cipher: CipherViewLike & { __copyable?: Record<string, boolean> }, field) => {
|
||||
return Boolean(cipher.__copyable?.[field]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the single item with value and translates its key", () => {
|
||||
const items = [
|
||||
{ key: "copyUsername", field: "username" as const },
|
||||
{ key: "copyPassword", field: "password" as const },
|
||||
];
|
||||
|
||||
(component.cipher as any).__copyable = {
|
||||
username: true,
|
||||
password: false,
|
||||
};
|
||||
|
||||
const result = component.findSingleCopyableItem(items);
|
||||
|
||||
expect(result).toEqual({
|
||||
key: "translated-copyUsername",
|
||||
field: "username",
|
||||
});
|
||||
expect(i18nService.t).toHaveBeenCalledWith("copyUsername");
|
||||
});
|
||||
|
||||
it("returns null when no items have a value", () => {
|
||||
const items = [
|
||||
{ key: "copyUsername", field: "username" as const },
|
||||
{ key: "copyPassword", field: "password" as const },
|
||||
];
|
||||
|
||||
(component.cipher as any).__copyable = {
|
||||
username: false,
|
||||
password: false,
|
||||
};
|
||||
|
||||
const result = component.findSingleCopyableItem(items);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when more than one item has a value", () => {
|
||||
const items = [
|
||||
{ key: "copyUsername", field: "username" as const },
|
||||
{ key: "copyPassword", field: "password" as const },
|
||||
];
|
||||
|
||||
(component.cipher as any).__copyable = {
|
||||
username: true,
|
||||
password: true,
|
||||
};
|
||||
|
||||
const result = component.findSingleCopyableItem(items);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("singleCopyableLogin", () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(CipherViewLikeUtils, "hasCopyableValue")
|
||||
.mockImplementation(
|
||||
(cipher: CipherViewLike & { __copyable?: Record<string, boolean> }, field) => {
|
||||
return Boolean(cipher.__copyable?.[field]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("returns username with special-case logic when password is hidden and both username/password exist and no totp", () => {
|
||||
(component.cipher as CipherView).viewPassword = false;
|
||||
|
||||
(component.cipher as any).__copyable = {
|
||||
username: true,
|
||||
password: true,
|
||||
totp: false,
|
||||
};
|
||||
|
||||
const result = component.singleCopyableLogin;
|
||||
|
||||
expect(result).toEqual({
|
||||
key: "translated-copyUsername",
|
||||
field: "username",
|
||||
});
|
||||
expect(i18nService.t).toHaveBeenCalledWith("copyUsername");
|
||||
});
|
||||
|
||||
it("returns null when password is hidden but multiple fields exist, ensuring username and totp are shown in the menu UI ", () => {
|
||||
(component.cipher as CipherView).viewPassword = false;
|
||||
|
||||
(component.cipher as any).__copyable = {
|
||||
username: true,
|
||||
password: true,
|
||||
totp: true,
|
||||
};
|
||||
|
||||
const result = component.singleCopyableLogin;
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to findSingleCopyableItem when password is visible", () => {
|
||||
const findSingleCopyableItemSpy = jest.spyOn(component, "findSingleCopyableItem");
|
||||
(component.cipher as CipherView).viewPassword = true;
|
||||
|
||||
void component.singleCopyableLogin;
|
||||
expect(findSingleCopyableItemSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("singleCopyableCard", () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(CipherViewLikeUtils, "hasCopyableValue")
|
||||
.mockImplementation(
|
||||
(cipher: CipherViewLike & { __copyable?: Record<string, boolean> }, field) => {
|
||||
return Boolean(cipher.__copyable?.[field]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("returns security code when it is the only available card value", () => {
|
||||
(component.cipher as any).__copyable = {
|
||||
securityCode: true,
|
||||
cardNumber: false,
|
||||
};
|
||||
|
||||
const result = component.singleCopyableCard;
|
||||
|
||||
expect(result).toEqual({
|
||||
key: "translated-securityCode",
|
||||
field: "securityCode",
|
||||
});
|
||||
expect(i18nService.t).toHaveBeenCalledWith("securityCode");
|
||||
});
|
||||
|
||||
it("returns null when both card number and security code are available", () => {
|
||||
(component.cipher as any).__copyable = {
|
||||
securityCode: true,
|
||||
cardNumber: true,
|
||||
};
|
||||
|
||||
const result = component.singleCopyableCard;
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("singleCopyableIdentity", () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(CipherViewLikeUtils, "hasCopyableValue")
|
||||
.mockImplementation(
|
||||
(cipher: CipherViewLike & { __copyable?: Record<string, boolean> }, field) => {
|
||||
return Boolean(cipher.__copyable?.[field]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the only copyable identity field", () => {
|
||||
(component.cipher as any).__copyable = {
|
||||
address: false,
|
||||
email: true,
|
||||
username: false,
|
||||
phone: false,
|
||||
};
|
||||
|
||||
const result = component.singleCopyableIdentity;
|
||||
|
||||
expect(result).toEqual({
|
||||
key: "translated-email",
|
||||
field: "email",
|
||||
});
|
||||
expect(i18nService.t).toHaveBeenCalledWith("email");
|
||||
});
|
||||
|
||||
it("returns null when multiple identity fields are available", () => {
|
||||
(component.cipher as any).__copyable = {
|
||||
address: true,
|
||||
email: true,
|
||||
username: false,
|
||||
phone: false,
|
||||
};
|
||||
|
||||
const result = component.singleCopyableIdentity;
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("has*Values in non-list view", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(CipherViewLikeUtils, "isCipherListView").mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("computes hasLoginValues from login fields", () => {
|
||||
(component.cipher as CipherView).login = {
|
||||
username: "user",
|
||||
password: null,
|
||||
totp: null,
|
||||
} as any;
|
||||
|
||||
expect(component.hasLoginValues).toBe(true);
|
||||
|
||||
(component.cipher as CipherView).login = {
|
||||
username: null,
|
||||
password: null,
|
||||
totp: null,
|
||||
} as any;
|
||||
|
||||
expect(component.hasLoginValues).toBe(false);
|
||||
});
|
||||
|
||||
it("computes hasCardValues from card fields", () => {
|
||||
(component.cipher as CipherView).card = { code: "123", number: null } as any;
|
||||
|
||||
expect(component.hasCardValues).toBe(true);
|
||||
|
||||
(component.cipher as CipherView).card = { code: null, number: null } as any;
|
||||
|
||||
expect(component.hasCardValues).toBe(false);
|
||||
});
|
||||
|
||||
it("computes hasIdentityValues from identity fields", () => {
|
||||
(component.cipher as CipherView).identity = {
|
||||
fullAddressForCopy: null,
|
||||
email: "test@example.com",
|
||||
username: null,
|
||||
phone: null,
|
||||
} as any;
|
||||
|
||||
expect(component.hasIdentityValues).toBe(true);
|
||||
|
||||
(component.cipher as CipherView).identity = {
|
||||
fullAddressForCopy: null,
|
||||
email: null,
|
||||
username: null,
|
||||
phone: null,
|
||||
} as any;
|
||||
|
||||
expect(component.hasIdentityValues).toBe(false);
|
||||
});
|
||||
|
||||
it("computes hasSecureNoteValue from notes", () => {
|
||||
(component.cipher as CipherView).notes = "Some note" as any;
|
||||
expect(component.hasSecureNoteValue).toBe(true);
|
||||
|
||||
(component.cipher as CipherView).notes = null as any;
|
||||
expect(component.hasSecureNoteValue).toBe(false);
|
||||
});
|
||||
|
||||
it("computes hasSshKeyValues from sshKey fields", () => {
|
||||
(component.cipher as CipherView).sshKey = {
|
||||
privateKey: "priv",
|
||||
publicKey: null,
|
||||
keyFingerprint: null,
|
||||
} as any;
|
||||
|
||||
expect(component.hasSshKeyValues).toBe(true);
|
||||
|
||||
(component.cipher as CipherView).sshKey = {
|
||||
privateKey: null,
|
||||
publicKey: null,
|
||||
keyFingerprint: null,
|
||||
} as any;
|
||||
|
||||
expect(component.hasSshKeyValues).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("has*Values in list view", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(CipherViewLikeUtils, "isCipherListView").mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("uses copyableFields for login values", () => {
|
||||
(component.cipher as CipherListView).copyableFields = [
|
||||
"LoginUsername",
|
||||
"CardNumber",
|
||||
] as CopyableCipherFields[];
|
||||
|
||||
expect(component.hasLoginValues).toBe(true);
|
||||
|
||||
(component.cipher as CipherListView).copyableFields = [
|
||||
"CardNumber",
|
||||
] as CopyableCipherFields[];
|
||||
|
||||
expect(component.hasLoginValues).toBe(false);
|
||||
});
|
||||
|
||||
it("uses copyableFields for card values", () => {
|
||||
(component.cipher as CipherListView).copyableFields = [
|
||||
"CardSecurityCode",
|
||||
] as CopyableCipherFields[];
|
||||
|
||||
expect(component.hasCardValues).toBe(true);
|
||||
|
||||
(component.cipher as CipherListView).copyableFields = [
|
||||
"LoginUsername",
|
||||
] as CopyableCipherFields[];
|
||||
|
||||
expect(component.hasCardValues).toBe(false);
|
||||
});
|
||||
|
||||
it("uses copyableFields for identity values", () => {
|
||||
(component.cipher as CipherListView).copyableFields = [
|
||||
"IdentityEmail",
|
||||
] as CopyableCipherFields[];
|
||||
|
||||
expect(component.hasIdentityValues).toBe(true);
|
||||
|
||||
(component.cipher as CipherListView).copyableFields = [
|
||||
"LoginUsername",
|
||||
] as CopyableCipherFields[];
|
||||
|
||||
expect(component.hasIdentityValues).toBe(false);
|
||||
});
|
||||
|
||||
it("uses copyableFields for secure note value", () => {
|
||||
(component.cipher as CipherListView).copyableFields = [
|
||||
"SecureNotes",
|
||||
] as CopyableCipherFields[];
|
||||
expect(component.hasSecureNoteValue).toBe(true);
|
||||
|
||||
(component.cipher as CipherListView).copyableFields = [
|
||||
"LoginUsername",
|
||||
] as CopyableCipherFields[];
|
||||
expect(component.hasSecureNoteValue).toBe(false);
|
||||
});
|
||||
|
||||
it("uses copyableFields for ssh key values", () => {
|
||||
(component.cipher as CipherListView).copyableFields = ["SshKey"] as CopyableCipherFields[];
|
||||
expect(component.hasSshKeyValues).toBe(true);
|
||||
|
||||
(component.cipher as CipherListView).copyableFields = [
|
||||
"LoginUsername",
|
||||
] as CopyableCipherFields[];
|
||||
expect(component.hasSshKeyValues).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -54,17 +54,20 @@ export class ItemCopyActionsComponent {
|
||||
{ key: "copyPassword", field: "password" },
|
||||
{ key: "copyVerificationCode", field: "totp" },
|
||||
];
|
||||
// If both the password and username are visible but the password is hidden, return the username
|
||||
// If both the password and username are visible but the password is hidden and there's no
|
||||
// totp code to copy return the username
|
||||
if (
|
||||
!this.cipher.viewPassword &&
|
||||
CipherViewLikeUtils.hasCopyableValue(this.cipher, "username") &&
|
||||
CipherViewLikeUtils.hasCopyableValue(this.cipher, "password")
|
||||
CipherViewLikeUtils.hasCopyableValue(this.cipher, "password") &&
|
||||
!CipherViewLikeUtils.hasCopyableValue(this.cipher, "totp")
|
||||
) {
|
||||
return {
|
||||
key: this.i18nService.t("copyUsername"),
|
||||
field: "username" as const,
|
||||
};
|
||||
}
|
||||
|
||||
return this.findSingleCopyableItem(loginItems);
|
||||
}
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ describe("ItemMoreOptionsComponent", () => {
|
||||
{ provide: RestrictedItemTypesService, useValue: { restricted$: of([]) } },
|
||||
{
|
||||
provide: CipherArchiveService,
|
||||
useValue: { userCanArchive$: () => of(true), hasArchiveFlagEnabled$: () => of(true) },
|
||||
useValue: { userCanArchive$: () => of(true), hasArchiveFlagEnabled$: of(true) },
|
||||
},
|
||||
{ provide: ToastService, useValue: { showToast: () => {} } },
|
||||
{ provide: Router, useValue: { navigate: () => Promise.resolve(true) } },
|
||||
|
||||
@@ -141,7 +141,7 @@ export class ItemMoreOptionsComponent {
|
||||
}),
|
||||
);
|
||||
|
||||
protected showArchive$: Observable<boolean> = this.cipherArchiveService.hasArchiveFlagEnabled$();
|
||||
protected showArchive$: Observable<boolean> = this.cipherArchiveService.hasArchiveFlagEnabled$;
|
||||
|
||||
protected canArchive$: Observable<boolean> = this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
|
||||
@@ -49,7 +49,7 @@ export class VaultSettingsV2Component implements OnInit, OnDestroy {
|
||||
this.userId$.pipe(switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId))),
|
||||
);
|
||||
|
||||
protected readonly showArchiveItem = toSignal(this.cipherArchiveService.hasArchiveFlagEnabled$());
|
||||
protected readonly showArchiveItem = toSignal(this.cipherArchiveService.hasArchiveFlagEnabled$);
|
||||
|
||||
protected readonly userHasArchivedItems = toSignal(
|
||||
this.userId$.pipe(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mock } from "jest-mock-extended";
|
||||
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
|
||||
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||
import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
|
||||
|
||||
import {
|
||||
@@ -23,6 +24,19 @@ describe("VaultPopoutWindow", () => {
|
||||
.spyOn(BrowserPopupUtils, "closeSingleActionPopout")
|
||||
.mockImplementation();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([]);
|
||||
jest.spyOn(BrowserApi, "updateWindowProperties").mockResolvedValue();
|
||||
global.chrome = {
|
||||
...global.chrome,
|
||||
runtime: {
|
||||
...global.chrome?.runtime,
|
||||
sendMessage: jest.fn().mockResolvedValue(undefined),
|
||||
getURL: jest.fn((path) => `chrome-extension://extension-id/${path}`),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
@@ -123,6 +137,32 @@ describe("VaultPopoutWindow", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("sends a message to refresh data when the popup is already open", async () => {
|
||||
const existingPopupTab = {
|
||||
id: 123,
|
||||
windowId: 456,
|
||||
url: `chrome-extension://extension-id/popup/index.html#/edit-cipher?singleActionPopout=${VaultPopoutType.addEditVaultItem}_${CipherType.Login}`,
|
||||
} as chrome.tabs.Tab;
|
||||
|
||||
jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([existingPopupTab]);
|
||||
const sendMessageSpy = jest.spyOn(chrome.runtime, "sendMessage");
|
||||
const updateWindowSpy = jest.spyOn(BrowserApi, "updateWindowProperties");
|
||||
|
||||
await openAddEditVaultItemPopout(
|
||||
mock<chrome.tabs.Tab>({ windowId: 1, url: "https://jest-testing-website.com" }),
|
||||
{
|
||||
cipherType: CipherType.Login,
|
||||
},
|
||||
);
|
||||
|
||||
expect(openPopoutSpy).not.toHaveBeenCalled();
|
||||
expect(sendMessageSpy).toHaveBeenCalledWith({
|
||||
command: "reloadAddEditCipherData",
|
||||
data: { cipherId: undefined, cipherType: CipherType.Login },
|
||||
});
|
||||
expect(updateWindowSpy).toHaveBeenCalledWith(456, { focused: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("closeAddEditVaultItemPopout", () => {
|
||||
|
||||
@@ -115,10 +115,26 @@ async function openAddEditVaultItemPopout(
|
||||
addEditCipherUrl += formatQueryString("uri", url);
|
||||
}
|
||||
|
||||
await BrowserPopupUtils.openPopout(addEditCipherUrl, {
|
||||
singleActionKey,
|
||||
senderWindowId: windowId,
|
||||
});
|
||||
const extensionUrl = chrome.runtime.getURL("popup/index.html");
|
||||
const existingPopupTabs = await BrowserApi.tabsQuery({ url: `${extensionUrl}*` });
|
||||
const existingPopup = existingPopupTabs.find((tab) =>
|
||||
tab.url?.includes(`singleActionPopout=${singleActionKey}`),
|
||||
);
|
||||
// Check if the an existing popup is already open
|
||||
try {
|
||||
await chrome.runtime.sendMessage({
|
||||
command: "reloadAddEditCipherData",
|
||||
data: { cipherId, cipherType },
|
||||
});
|
||||
await BrowserApi.updateWindowProperties(existingPopup.windowId, {
|
||||
focused: true,
|
||||
});
|
||||
} catch {
|
||||
await BrowserPopupUtils.openPopout(addEditCipherUrl, {
|
||||
singleActionKey,
|
||||
senderWindowId: windowId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@bitwarden/cli",
|
||||
"description": "A secure and free password manager for all of your devices.",
|
||||
"version": "2025.11.0",
|
||||
"version": "2025.12.0",
|
||||
"keywords": [
|
||||
"bitwarden",
|
||||
"password",
|
||||
@@ -75,20 +75,20 @@
|
||||
"inquirer": "8.2.6",
|
||||
"jsdom": "26.1.0",
|
||||
"jszip": "3.10.1",
|
||||
"koa": "2.16.3",
|
||||
"koa": "3.1.1",
|
||||
"koa-bodyparser": "4.4.1",
|
||||
"koa-json": "2.0.2",
|
||||
"lowdb": "1.0.0",
|
||||
"lunr": "2.3.9",
|
||||
"multer": "2.0.2",
|
||||
"node-fetch": "2.6.12",
|
||||
"node-forge": "1.3.1",
|
||||
"open": "10.1.2",
|
||||
"node-forge": "1.3.2",
|
||||
"open": "11.0.0",
|
||||
"papaparse": "5.5.3",
|
||||
"proper-lockfile": "4.1.2",
|
||||
"rxjs": "7.8.1",
|
||||
"semver": "7.7.3",
|
||||
"tldts": "7.0.18",
|
||||
"tldts": "7.0.19",
|
||||
"zxcvbn": "4.4.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import { TwoFactorService, TwoFactorApiService } from "@bitwarden/common/auth/tw
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
|
||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
@@ -81,6 +82,7 @@ export class LoginCommand {
|
||||
protected ssoUrlService: SsoUrlService,
|
||||
protected i18nService: I18nService,
|
||||
protected masterPasswordService: MasterPasswordServiceAbstraction,
|
||||
protected encryptedMigrator: EncryptedMigrator,
|
||||
) {}
|
||||
|
||||
async run(email: string, password: string, options: OptionValues) {
|
||||
@@ -367,6 +369,8 @@ export class LoginCommand {
|
||||
}
|
||||
}
|
||||
|
||||
await this.encryptedMigrator.runMigrations(response.userId, password);
|
||||
|
||||
return await this.handleSuccessResponse(response);
|
||||
} catch (e) {
|
||||
if (
|
||||
|
||||
@@ -182,6 +182,7 @@ export abstract class BaseProgram {
|
||||
this.serviceContainer.organizationApiService,
|
||||
this.serviceContainer.logout,
|
||||
this.serviceContainer.i18nService,
|
||||
this.serviceContainer.encryptedMigrator,
|
||||
this.serviceContainer.masterPasswordUnlockService,
|
||||
this.serviceContainer.configService,
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use
|
||||
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
|
||||
import { MasterPasswordVerificationResponse } from "@bitwarden/common/auth/types/verification";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
|
||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
@@ -40,6 +41,7 @@ describe("UnlockCommand", () => {
|
||||
const organizationApiService = mock<OrganizationApiServiceAbstraction>();
|
||||
const logout = jest.fn();
|
||||
const i18nService = mock<I18nService>();
|
||||
const encryptedMigrator = mock<EncryptedMigrator>();
|
||||
const masterPasswordUnlockService = mock<MasterPasswordUnlockService>();
|
||||
const configService = mock<ConfigService>();
|
||||
|
||||
@@ -92,6 +94,7 @@ describe("UnlockCommand", () => {
|
||||
organizationApiService,
|
||||
logout,
|
||||
i18nService,
|
||||
encryptedMigrator,
|
||||
masterPasswordUnlockService,
|
||||
configService,
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { VerificationType } from "@bitwarden/common/auth/enums/verification-type
|
||||
import { MasterPasswordVerification } from "@bitwarden/common/auth/types/verification";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
|
||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
@@ -38,6 +39,7 @@ export class UnlockCommand {
|
||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||
private logout: () => Promise<void>,
|
||||
private i18nService: I18nService,
|
||||
private encryptedMigrator: EncryptedMigrator,
|
||||
private masterPasswordUnlockService: MasterPasswordUnlockService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
@@ -116,6 +118,8 @@ export class UnlockCommand {
|
||||
}
|
||||
}
|
||||
|
||||
await this.encryptedMigrator.runMigrations(userId, password);
|
||||
|
||||
return this.successResponse();
|
||||
}
|
||||
|
||||
|
||||
@@ -176,6 +176,7 @@ export class OssServeConfigurator {
|
||||
this.serviceContainer.organizationApiService,
|
||||
async () => await this.serviceContainer.logout(),
|
||||
this.serviceContainer.i18nService,
|
||||
this.serviceContainer.encryptedMigrator,
|
||||
this.serviceContainer.masterPasswordUnlockService,
|
||||
this.serviceContainer.configService,
|
||||
);
|
||||
|
||||
@@ -195,6 +195,7 @@ export class Program extends BaseProgram {
|
||||
this.serviceContainer.ssoUrlService,
|
||||
this.serviceContainer.i18nService,
|
||||
this.serviceContainer.masterPasswordService,
|
||||
this.serviceContainer.encryptedMigrator,
|
||||
);
|
||||
const response = await command.run(email, password, options);
|
||||
this.processResponse(response, true);
|
||||
@@ -277,6 +278,11 @@ export class Program extends BaseProgram {
|
||||
})
|
||||
.option("--check", "Check lock status.", async () => {
|
||||
await this.exitIfNotAuthed();
|
||||
const userId = (await firstValueFrom(this.serviceContainer.accountService.activeAccount$))
|
||||
?.id;
|
||||
await this.serviceContainer.userAutoUnlockKeyService.setUserKeyInMemoryIfAutoUserKeySet(
|
||||
userId,
|
||||
);
|
||||
|
||||
const authStatus = await this.serviceContainer.authService.getAuthStatus();
|
||||
if (authStatus === AuthenticationStatus.Unlocked) {
|
||||
@@ -306,6 +312,7 @@ export class Program extends BaseProgram {
|
||||
this.serviceContainer.organizationApiService,
|
||||
async () => await this.serviceContainer.logout(),
|
||||
this.serviceContainer.i18nService,
|
||||
this.serviceContainer.encryptedMigrator,
|
||||
this.serviceContainer.masterPasswordUnlockService,
|
||||
this.serviceContainer.configService,
|
||||
);
|
||||
|
||||
@@ -76,6 +76,10 @@ import {
|
||||
import { EncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/encrypt.service.implementation";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||
import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation";
|
||||
import { DefaultEncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/default-encrypted-migrator";
|
||||
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
|
||||
import { DefaultChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service";
|
||||
import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service";
|
||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service";
|
||||
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
@@ -324,6 +328,7 @@ export class ServiceContainer {
|
||||
cipherEncryptionService: CipherEncryptionService;
|
||||
restrictedItemTypesService: RestrictedItemTypesService;
|
||||
cliRestrictedItemTypesService: CliRestrictedItemTypesService;
|
||||
encryptedMigrator: EncryptedMigrator;
|
||||
securityStateService: SecurityStateService;
|
||||
masterPasswordUnlockService: MasterPasswordUnlockService;
|
||||
cipherArchiveService: CipherArchiveService;
|
||||
@@ -518,7 +523,11 @@ export class ServiceContainer {
|
||||
this.ssoUrlService = new SsoUrlService();
|
||||
|
||||
this.organizationService = new DefaultOrganizationService(this.stateProvider);
|
||||
this.policyService = new DefaultPolicyService(this.stateProvider, this.organizationService);
|
||||
this.policyService = new DefaultPolicyService(
|
||||
this.stateProvider,
|
||||
this.organizationService,
|
||||
this.accountService,
|
||||
);
|
||||
|
||||
this.vaultTimeoutSettingsService = new DefaultVaultTimeoutSettingsService(
|
||||
this.accountService,
|
||||
@@ -971,6 +980,16 @@ export class ServiceContainer {
|
||||
);
|
||||
|
||||
this.masterPasswordApiService = new MasterPasswordApiService(this.apiService, this.logService);
|
||||
const changeKdfApiService = new DefaultChangeKdfApiService(this.apiService);
|
||||
const changeKdfService = new DefaultChangeKdfService(changeKdfApiService, this.sdkService);
|
||||
this.encryptedMigrator = new DefaultEncryptedMigrator(
|
||||
this.kdfConfigService,
|
||||
changeKdfService,
|
||||
this.logService,
|
||||
this.configService,
|
||||
this.masterPasswordService,
|
||||
this.syncService,
|
||||
);
|
||||
}
|
||||
|
||||
async logout() {
|
||||
|
||||
8
apps/desktop/desktop_native/Cargo.lock
generated
8
apps/desktop/desktop_native/Cargo.lock
generated
@@ -1863,9 +1863,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mockall"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2"
|
||||
checksum = "f58d964098a5f9c6b63d0798e5372fd04708193510a7af313c22e9f29b7b620b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"downcast",
|
||||
@@ -1877,9 +1877,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mockall_derive"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898"
|
||||
checksum = "ca41ce716dda6a9be188b385aa78ee5260fc25cd3802cb2a8afdc6afbe6b6dbf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"proc-macro2",
|
||||
|
||||
@@ -9,7 +9,7 @@ publish.workspace = true
|
||||
anyhow = { workspace = true }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
mockall = "=0.13.1"
|
||||
mockall = "=0.14.0"
|
||||
serial_test = "=3.2.0"
|
||||
tracing.workspace = true
|
||||
windows = { workspace = true, features = [
|
||||
|
||||
@@ -272,6 +272,7 @@ mod tests {
|
||||
#[serial]
|
||||
fn send_input_succeeds() {
|
||||
let ctxi = MockInputOperations::send_input_context();
|
||||
ctxi.checkpoint();
|
||||
ctxi.expect().returning(|_| 1);
|
||||
|
||||
send_input::<MockInputOperations, MockErrorOperations>(vec![build_unicode_input(
|
||||
@@ -279,6 +280,8 @@ mod tests {
|
||||
0,
|
||||
)])
|
||||
.unwrap();
|
||||
|
||||
drop(ctxi);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -288,9 +291,11 @@ mod tests {
|
||||
)]
|
||||
fn send_input_fails_sent_zero() {
|
||||
let ctxi = MockInputOperations::send_input_context();
|
||||
ctxi.checkpoint();
|
||||
ctxi.expect().returning(|_| 0);
|
||||
|
||||
let ctxge = MockErrorOperations::get_last_error_context();
|
||||
ctxge.checkpoint();
|
||||
ctxge.expect().returning(|| WIN32_ERROR(1));
|
||||
|
||||
send_input::<MockInputOperations, MockErrorOperations>(vec![build_unicode_input(
|
||||
@@ -298,6 +303,9 @@ mod tests {
|
||||
0,
|
||||
)])
|
||||
.unwrap();
|
||||
|
||||
drop(ctxge);
|
||||
drop(ctxi);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -305,9 +313,11 @@ mod tests {
|
||||
#[should_panic(expected = "SendInput does not match expected. sent: 2, expected: 1")]
|
||||
fn send_input_fails_sent_mismatch() {
|
||||
let ctxi = MockInputOperations::send_input_context();
|
||||
ctxi.checkpoint();
|
||||
ctxi.expect().returning(|_| 2);
|
||||
|
||||
let ctxge = MockErrorOperations::get_last_error_context();
|
||||
ctxge.checkpoint();
|
||||
ctxge.expect().returning(|| WIN32_ERROR(1));
|
||||
|
||||
send_input::<MockInputOperations, MockErrorOperations>(vec![build_unicode_input(
|
||||
@@ -315,5 +325,8 @@ mod tests {
|
||||
0,
|
||||
)])
|
||||
.unwrap();
|
||||
|
||||
drop(ctxge);
|
||||
drop(ctxi);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +186,7 @@ mod tests {
|
||||
let mut mock_handle = MockWindowHandleOperations::new();
|
||||
|
||||
let ctxse = MockErrorOperations::set_last_error_context();
|
||||
ctxse.checkpoint();
|
||||
ctxse
|
||||
.expect()
|
||||
.once()
|
||||
@@ -198,6 +199,7 @@ mod tests {
|
||||
.returning(|| Ok(0));
|
||||
|
||||
let ctxge = MockErrorOperations::get_last_error_context();
|
||||
ctxge.checkpoint();
|
||||
ctxge.expect().returning(|| WIN32_ERROR(0));
|
||||
|
||||
let len = get_window_title_length::<MockWindowHandleOperations, MockErrorOperations>(
|
||||
@@ -206,6 +208,9 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(len, 0);
|
||||
|
||||
drop(ctxge);
|
||||
drop(ctxse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -215,6 +220,7 @@ mod tests {
|
||||
let mut mock_handle = MockWindowHandleOperations::new();
|
||||
|
||||
let ctxse = MockErrorOperations::set_last_error_context();
|
||||
ctxse.checkpoint();
|
||||
ctxse.expect().with(predicate::eq(0)).returning(|_| {});
|
||||
|
||||
mock_handle
|
||||
@@ -223,13 +229,18 @@ mod tests {
|
||||
.returning(|| Ok(0));
|
||||
|
||||
let ctxge = MockErrorOperations::get_last_error_context();
|
||||
ctxge.checkpoint();
|
||||
ctxge.expect().returning(|| WIN32_ERROR(1));
|
||||
|
||||
get_window_title_length::<MockWindowHandleOperations, MockErrorOperations>(&mock_handle)
|
||||
.unwrap();
|
||||
|
||||
drop(ctxge);
|
||||
drop(ctxse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn get_window_title_succeeds() {
|
||||
let mut mock_handle = MockWindowHandleOperations::new();
|
||||
|
||||
@@ -246,11 +257,11 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(title.len(), 43); // That extra slot in the buffer for null char
|
||||
|
||||
assert_eq!(title, "*******************************************");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn get_window_title_returns_empty_string() {
|
||||
let mock_handle = MockWindowHandleOperations::new();
|
||||
|
||||
@@ -273,10 +284,13 @@ mod tests {
|
||||
.returning(|_| Ok(0));
|
||||
|
||||
let ctxge = MockErrorOperations::get_last_error_context();
|
||||
ctxge.checkpoint();
|
||||
ctxge.expect().returning(|| WIN32_ERROR(1));
|
||||
|
||||
get_window_title::<MockWindowHandleOperations, MockErrorOperations>(&mock_handle, 42)
|
||||
.unwrap();
|
||||
|
||||
drop(ctxge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -290,9 +304,12 @@ mod tests {
|
||||
.returning(|_| Ok(0));
|
||||
|
||||
let ctxge = MockErrorOperations::get_last_error_context();
|
||||
ctxge.checkpoint();
|
||||
ctxge.expect().returning(|| WIN32_ERROR(0));
|
||||
|
||||
get_window_title::<MockWindowHandleOperations, MockErrorOperations>(&mock_handle, 42)
|
||||
.unwrap();
|
||||
|
||||
drop(ctxge);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,8 +61,8 @@ impl InstalledBrowserRetriever for DefaultInstalledBrowserRetriever {
|
||||
let mut browsers = Vec::with_capacity(SUPPORTED_BROWSER_MAP.len());
|
||||
|
||||
for (browser, config) in SUPPORTED_BROWSER_MAP.iter() {
|
||||
let data_dir = get_browser_data_dir(config)?;
|
||||
if data_dir.exists() {
|
||||
let data_dir = get_and_validate_data_dir(config);
|
||||
if data_dir.is_ok() {
|
||||
browsers.push((*browser).to_string());
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,7 @@ pub async fn import_logins(
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct BrowserConfig {
|
||||
pub name: &'static str,
|
||||
pub data_dir: &'static str,
|
||||
pub data_dir: &'static [&'static str],
|
||||
}
|
||||
|
||||
pub(crate) static SUPPORTED_BROWSER_MAP: LazyLock<
|
||||
@@ -126,11 +126,19 @@ pub(crate) static SUPPORTED_BROWSER_MAP: LazyLock<
|
||||
.collect::<std::collections::HashMap<_, _>>()
|
||||
});
|
||||
|
||||
fn get_browser_data_dir(config: &BrowserConfig) -> Result<PathBuf> {
|
||||
let dir = dirs::home_dir()
|
||||
.ok_or_else(|| anyhow!("Home directory not found"))?
|
||||
.join(config.data_dir);
|
||||
Ok(dir)
|
||||
fn get_and_validate_data_dir(config: &BrowserConfig) -> Result<PathBuf> {
|
||||
for data_dir in config.data_dir.iter() {
|
||||
let dir = dirs::home_dir()
|
||||
.ok_or_else(|| anyhow!("Home directory not found"))?
|
||||
.join(data_dir);
|
||||
if dir.exists() {
|
||||
return Ok(dir);
|
||||
}
|
||||
}
|
||||
Err(anyhow!(
|
||||
"Browser user data directory '{:?}' not found",
|
||||
config.data_dir
|
||||
))
|
||||
}
|
||||
|
||||
//
|
||||
@@ -174,13 +182,7 @@ fn load_local_state_for_browser(browser_name: &String) -> Result<(PathBuf, Local
|
||||
.get(browser_name.as_str())
|
||||
.ok_or_else(|| anyhow!("Unsupported browser: {}", browser_name))?;
|
||||
|
||||
let data_dir = get_browser_data_dir(config)?;
|
||||
if !data_dir.exists() {
|
||||
return Err(anyhow!(
|
||||
"Browser user data directory '{}' not found",
|
||||
data_dir.display()
|
||||
));
|
||||
}
|
||||
let data_dir = get_and_validate_data_dir(config)?;
|
||||
|
||||
let local_state = load_local_state(&data_dir)?;
|
||||
|
||||
|
||||
@@ -18,19 +18,22 @@ use crate::{
|
||||
pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[
|
||||
BrowserConfig {
|
||||
name: "Chrome",
|
||||
data_dir: ".config/google-chrome",
|
||||
data_dir: &[".config/google-chrome"],
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Chromium",
|
||||
data_dir: "snap/chromium/common/chromium",
|
||||
data_dir: &["snap/chromium/common/chromium"],
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Brave",
|
||||
data_dir: "snap/brave/current/.config/BraveSoftware/Brave-Browser",
|
||||
data_dir: &[
|
||||
"snap/brave/current/.config/BraveSoftware/Brave-Browser",
|
||||
".config/BraveSoftware/Brave-Browser",
|
||||
],
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Opera",
|
||||
data_dir: "snap/opera/current/.config/opera",
|
||||
data_dir: &["snap/opera/current/.config/opera", ".config/opera"],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -14,31 +14,31 @@ use crate::{
|
||||
pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[
|
||||
BrowserConfig {
|
||||
name: "Chrome",
|
||||
data_dir: "Library/Application Support/Google/Chrome",
|
||||
data_dir: &["Library/Application Support/Google/Chrome"],
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Chromium",
|
||||
data_dir: "Library/Application Support/Chromium",
|
||||
data_dir: &["Library/Application Support/Chromium"],
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Microsoft Edge",
|
||||
data_dir: "Library/Application Support/Microsoft Edge",
|
||||
data_dir: &["Library/Application Support/Microsoft Edge"],
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Brave",
|
||||
data_dir: "Library/Application Support/BraveSoftware/Brave-Browser",
|
||||
data_dir: &["Library/Application Support/BraveSoftware/Brave-Browser"],
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Arc",
|
||||
data_dir: "Library/Application Support/Arc/User Data",
|
||||
data_dir: &["Library/Application Support/Arc/User Data"],
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Opera",
|
||||
data_dir: "Library/Application Support/com.operasoftware.Opera",
|
||||
data_dir: &["Library/Application Support/com.operasoftware.Opera"],
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Vivaldi",
|
||||
data_dir: "Library/Application Support/Vivaldi",
|
||||
data_dir: &["Library/Application Support/Vivaldi"],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -25,27 +25,27 @@ pub use signature::*;
|
||||
pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[
|
||||
BrowserConfig {
|
||||
name: "Brave",
|
||||
data_dir: "AppData/Local/BraveSoftware/Brave-Browser/User Data",
|
||||
data_dir: &["AppData/Local/BraveSoftware/Brave-Browser/User Data"],
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Chrome",
|
||||
data_dir: "AppData/Local/Google/Chrome/User Data",
|
||||
data_dir: &["AppData/Local/Google/Chrome/User Data"],
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Chromium",
|
||||
data_dir: "AppData/Local/Chromium/User Data",
|
||||
data_dir: &["AppData/Local/Chromium/User Data"],
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Microsoft Edge",
|
||||
data_dir: "AppData/Local/Microsoft/Edge/User Data",
|
||||
data_dir: &["AppData/Local/Microsoft/Edge/User Data"],
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Opera",
|
||||
data_dir: "AppData/Roaming/Opera Software/Opera Stable",
|
||||
data_dir: &["AppData/Roaming/Opera Software/Opera Stable"],
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Vivaldi",
|
||||
data_dir: "AppData/Local/Vivaldi/User Data",
|
||||
data_dir: &["AppData/Local/Vivaldi/User Data"],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ mod tests {
|
||||
for (key, meta) in map.iter() {
|
||||
assert_eq!(&meta.id, key);
|
||||
assert_eq!(meta.instructions, "chromium");
|
||||
assert!(meta.loaders.iter().any(|l| *l == "file"));
|
||||
assert!(meta.loaders.contains(&"file"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ mod tests {
|
||||
for (key, meta) in map.iter() {
|
||||
assert_eq!(&meta.id, key);
|
||||
assert_eq!(meta.instructions, "chromium");
|
||||
assert!(meta.loaders.iter().any(|l| *l == "file"));
|
||||
assert!(meta.loaders.contains(&"file"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ mod tests {
|
||||
for (key, meta) in map.iter() {
|
||||
assert_eq!(&meta.id, key);
|
||||
assert_eq!(meta.instructions, "chromium");
|
||||
assert!(meta.loaders.iter().any(|l| *l == "file"));
|
||||
assert!(meta.loaders.contains(&"file"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,16 +3,12 @@ use anyhow::{anyhow, Result};
|
||||
|
||||
#[allow(clippy::module_inception)]
|
||||
#[cfg_attr(target_os = "linux", path = "unix.rs")]
|
||||
#[cfg_attr(target_os = "macos", path = "macos.rs")]
|
||||
#[cfg_attr(target_os = "windows", path = "windows.rs")]
|
||||
#[cfg_attr(target_os = "macos", path = "unimplemented.rs")]
|
||||
#[cfg_attr(target_os = "windows", path = "unimplemented.rs")]
|
||||
mod biometric;
|
||||
|
||||
pub use biometric::Biometric;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub mod windows_focus;
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD as base64_engine, Engine};
|
||||
pub use biometric::Biometric;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::crypto::{self, CipherString};
|
||||
|
||||
@@ -2,7 +2,7 @@ use anyhow::{bail, Result};
|
||||
|
||||
use crate::biometric::{KeyMaterial, OsDerivedKey};
|
||||
|
||||
/// The MacOS implementation of the biometric trait.
|
||||
/// Unimplemented stub for unsupported platforms
|
||||
pub struct Biometric {}
|
||||
|
||||
impl super::BiometricTrait for Biometric {
|
||||
@@ -1,240 +0,0 @@
|
||||
use std::{ffi::c_void, str::FromStr};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::{engine::general_purpose::STANDARD as base64_engine, Engine};
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
use windows::{
|
||||
core::{factory, HSTRING},
|
||||
Security::Credentials::UI::{
|
||||
UserConsentVerificationResult, UserConsentVerifier, UserConsentVerifierAvailability,
|
||||
},
|
||||
Win32::{
|
||||
Foundation::HWND, System::WinRT::IUserConsentVerifierInterop,
|
||||
UI::WindowsAndMessaging::GetForegroundWindow,
|
||||
},
|
||||
};
|
||||
use windows_future::IAsyncOperation;
|
||||
|
||||
use super::{decrypt, encrypt, windows_focus::set_focus};
|
||||
use crate::{
|
||||
biometric::{KeyMaterial, OsDerivedKey},
|
||||
crypto::CipherString,
|
||||
};
|
||||
|
||||
/// The Windows OS implementation of the biometric trait.
|
||||
pub struct Biometric {}
|
||||
|
||||
impl super::BiometricTrait for Biometric {
|
||||
// FIXME: Remove unwraps! They panic and terminate the whole application.
|
||||
#[allow(clippy::unwrap_used)]
|
||||
async fn prompt(hwnd: Vec<u8>, message: String) -> Result<bool> {
|
||||
let h = isize::from_le_bytes(hwnd.clone().try_into().unwrap());
|
||||
|
||||
let h = h as *mut c_void;
|
||||
let window = HWND(h);
|
||||
|
||||
// The Windows Hello prompt is displayed inside the application window. For best result we
|
||||
// should set the window to the foreground and focus it.
|
||||
set_focus(window);
|
||||
|
||||
// Windows Hello prompt must be in foreground, focused, otherwise the face or fingerprint
|
||||
// unlock will not work. We get the current foreground window, which will either be the
|
||||
// Bitwarden desktop app or the browser extension.
|
||||
let foreground_window = unsafe { GetForegroundWindow() };
|
||||
|
||||
let interop = factory::<UserConsentVerifier, IUserConsentVerifierInterop>()?;
|
||||
let operation: IAsyncOperation<UserConsentVerificationResult> = unsafe {
|
||||
interop.RequestVerificationForWindowAsync(foreground_window, &HSTRING::from(message))?
|
||||
};
|
||||
let result = operation.get()?;
|
||||
|
||||
match result {
|
||||
UserConsentVerificationResult::Verified => Ok(true),
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
async fn available() -> Result<bool> {
|
||||
let ucv_available = UserConsentVerifier::CheckAvailabilityAsync()?.get()?;
|
||||
|
||||
match ucv_available {
|
||||
UserConsentVerifierAvailability::Available => Ok(true),
|
||||
// TODO: look into removing this and making the check more ad-hoc
|
||||
UserConsentVerifierAvailability::DeviceBusy => Ok(true),
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn derive_key_material(challenge_str: Option<&str>) -> Result<OsDerivedKey> {
|
||||
let challenge: [u8; 16] = match challenge_str {
|
||||
Some(challenge_str) => base64_engine
|
||||
.decode(challenge_str)?
|
||||
.try_into()
|
||||
.map_err(|e: Vec<_>| anyhow!("Expect length {}, got {}", 16, e.len()))?,
|
||||
None => random_challenge(),
|
||||
};
|
||||
|
||||
// Uses a key derived from the iv. This key is not intended to add any security
|
||||
// but only a place-holder
|
||||
let key = Sha256::digest(challenge);
|
||||
let key_b64 = base64_engine.encode(key);
|
||||
let iv_b64 = base64_engine.encode(challenge);
|
||||
Ok(OsDerivedKey { key_b64, iv_b64 })
|
||||
}
|
||||
|
||||
async fn set_biometric_secret(
|
||||
service: &str,
|
||||
account: &str,
|
||||
secret: &str,
|
||||
key_material: Option<KeyMaterial>,
|
||||
iv_b64: &str,
|
||||
) -> Result<String> {
|
||||
let key_material = key_material.ok_or(anyhow!(
|
||||
"Key material is required for Windows Hello protected keys"
|
||||
))?;
|
||||
|
||||
let encrypted_secret = encrypt(secret, &key_material, iv_b64)?;
|
||||
crate::password::set_password(service, account, &encrypted_secret).await?;
|
||||
Ok(encrypted_secret)
|
||||
}
|
||||
|
||||
async fn get_biometric_secret(
|
||||
service: &str,
|
||||
account: &str,
|
||||
key_material: Option<KeyMaterial>,
|
||||
) -> Result<String> {
|
||||
let key_material = key_material.ok_or(anyhow!(
|
||||
"Key material is required for Windows Hello protected keys"
|
||||
))?;
|
||||
|
||||
let encrypted_secret = crate::password::get_password(service, account).await?;
|
||||
match CipherString::from_str(&encrypted_secret) {
|
||||
Ok(secret) => {
|
||||
// If the secret is a CipherString, it is encrypted and we need to decrypt it.
|
||||
let secret = decrypt(&secret, &key_material)?;
|
||||
Ok(secret)
|
||||
}
|
||||
Err(_) => {
|
||||
// If the secret is not a CipherString, it is not encrypted and we can return it
|
||||
// directly.
|
||||
Ok(encrypted_secret)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn random_challenge() -> [u8; 16] {
|
||||
let mut challenge = [0u8; 16];
|
||||
rand::rng().fill_bytes(&mut challenge);
|
||||
challenge
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::biometric::BiometricTrait;
|
||||
|
||||
#[test]
|
||||
fn test_derive_key_material() {
|
||||
let iv_input = "l9fhDUP/wDJcKwmEzcb/3w==";
|
||||
let result = <Biometric as BiometricTrait>::derive_key_material(Some(iv_input)).unwrap();
|
||||
let key = base64_engine.decode(result.key_b64).unwrap();
|
||||
assert_eq!(key.len(), 32);
|
||||
assert_eq!(result.iv_b64, iv_input)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_key_material_no_iv() {
|
||||
let result = <Biometric as BiometricTrait>::derive_key_material(None).unwrap();
|
||||
let key = base64_engine.decode(result.key_b64).unwrap();
|
||||
assert_eq!(key.len(), 32);
|
||||
let iv = base64_engine.decode(result.iv_b64).unwrap();
|
||||
assert_eq!(iv.len(), 16);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg(feature = "manual_test")]
|
||||
async fn test_prompt() {
|
||||
<Biometric as BiometricTrait>::prompt(
|
||||
vec![0, 0, 0, 0, 0, 0, 0, 0],
|
||||
String::from("Hello from Rust"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg(feature = "manual_test")]
|
||||
async fn test_available() {
|
||||
assert!(<Biometric as BiometricTrait>::available().await.unwrap())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg(feature = "manual_test")]
|
||||
async fn get_biometric_secret_requires_key() {
|
||||
let result = <Biometric as BiometricTrait>::get_biometric_secret("", "", None).await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err().to_string(),
|
||||
"Key material is required for Windows Hello protected keys"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg(feature = "manual_test")]
|
||||
async fn get_biometric_secret_handles_unencrypted_secret() {
|
||||
let test = "test";
|
||||
let secret = "password";
|
||||
let key_material = KeyMaterial {
|
||||
os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(),
|
||||
client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()),
|
||||
};
|
||||
crate::password::set_password(test, test, secret)
|
||||
.await
|
||||
.unwrap();
|
||||
let result =
|
||||
<Biometric as BiometricTrait>::get_biometric_secret(test, test, Some(key_material))
|
||||
.await
|
||||
.unwrap();
|
||||
crate::password::delete_password("test", "test")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, secret);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg(feature = "manual_test")]
|
||||
async fn get_biometric_secret_handles_encrypted_secret() {
|
||||
let test = "test";
|
||||
let secret =
|
||||
CipherString::from_str("0.l9fhDUP/wDJcKwmEzcb/3w==|uP4LcqoCCj5FxBDP77NV6Q==").unwrap(); // output from test_encrypt
|
||||
let key_material = KeyMaterial {
|
||||
os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(),
|
||||
client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()),
|
||||
};
|
||||
crate::password::set_password(test, test, &secret.to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result =
|
||||
<Biometric as BiometricTrait>::get_biometric_secret(test, test, Some(key_material))
|
||||
.await
|
||||
.unwrap();
|
||||
crate::password::delete_password("test", "test")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, "secret");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_biometric_secret_requires_key() {
|
||||
let result =
|
||||
<Biometric as BiometricTrait>::set_biometric_secret("", "", "", None, "").await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err().to_string(),
|
||||
"Key material is required for Windows Hello protected keys"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
use windows::{
|
||||
core::s,
|
||||
Win32::{
|
||||
Foundation::HWND,
|
||||
UI::{
|
||||
Input::KeyboardAndMouse::SetFocus,
|
||||
WindowsAndMessaging::{FindWindowA, SetForegroundWindow},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/// Searches for a window that looks like a security prompt and set it as focused.
|
||||
/// Only works when the process has permission to foreground, either by being in foreground
|
||||
/// Or by being given foreground permission https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setforegroundwindow#remarks
|
||||
pub fn focus_security_prompt() {
|
||||
let class_name = s!("Credential Dialog Xaml Host");
|
||||
let hwnd = unsafe { FindWindowA(class_name, None) };
|
||||
if let Ok(hwnd) = hwnd {
|
||||
set_focus(hwnd);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_focus(window: HWND) {
|
||||
unsafe {
|
||||
let _ = SetForegroundWindow(window);
|
||||
let _ = SetFocus(Some(window));
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"build": "node scripts/build.js",
|
||||
"build": "napi build --platform --js false",
|
||||
"test": "cargo test"
|
||||
},
|
||||
"author": "",
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const isRelease = args.includes('--release');
|
||||
|
||||
if (isRelease) {
|
||||
console.log('Building release mode.');
|
||||
} else {
|
||||
console.log('Building debug mode.');
|
||||
process.env.RUST_LOG = 'debug';
|
||||
}
|
||||
|
||||
execSync(`napi build --platform --js false`, { stdio: 'inherit', env: process.env });
|
||||
@@ -961,7 +961,7 @@ pub mod logging {
|
||||
};
|
||||
use tracing::Level;
|
||||
use tracing_subscriber::{
|
||||
filter::EnvFilter,
|
||||
filter::{EnvFilter, LevelFilter},
|
||||
fmt::format::{DefaultVisitor, Writer},
|
||||
layer::SubscriberExt,
|
||||
util::SubscriberInitExt,
|
||||
@@ -1049,17 +1049,9 @@ pub mod logging {
|
||||
pub fn init_napi_log(js_log_fn: ThreadsafeFunction<(LogLevel, String), CalleeHandled>) {
|
||||
let _ = JS_LOGGER.0.set(js_log_fn);
|
||||
|
||||
// the log level hierarchy is determined by:
|
||||
// - if RUST_LOG is detected at runtime
|
||||
// - if RUST_LOG is provided at compile time
|
||||
// - default to INFO
|
||||
let filter = EnvFilter::builder()
|
||||
.with_default_directive(
|
||||
option_env!("RUST_LOG")
|
||||
.unwrap_or("info")
|
||||
.parse()
|
||||
.expect("should provide valid log level at compile time."),
|
||||
)
|
||||
// set the default log level to INFO.
|
||||
.with_default_directive(LevelFilter::INFO.into())
|
||||
// parse directives from the RUST_LOG environment variable,
|
||||
// overriding the default directive for matching targets.
|
||||
.from_env_lossy();
|
||||
|
||||
@@ -8,9 +8,6 @@ use tracing_subscriber::{
|
||||
fmt, layer::SubscriberExt as _, util::SubscriberInitExt as _, EnvFilter, Layer as _,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
embed_plist::embed_info_plist!("../../../resources/info.desktop_proxy.plist");
|
||||
|
||||
@@ -64,9 +61,6 @@ fn init_logging(log_path: &Path, console_level: LevelFilter, file_level: LevelFi
|
||||
#[allow(clippy::unwrap_used)]
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() {
|
||||
#[cfg(target_os = "windows")]
|
||||
let should_foreground = windows::allow_foreground();
|
||||
|
||||
let sock_path = desktop_core::ipc::path("bw");
|
||||
|
||||
let log_path = {
|
||||
@@ -158,9 +152,6 @@ async fn main() {
|
||||
|
||||
// Listen to stdin and send messages to ipc processor.
|
||||
msg = stdin.next() => {
|
||||
#[cfg(target_os = "windows")]
|
||||
should_foreground.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
match msg {
|
||||
Some(Ok(msg)) => {
|
||||
let msg = String::from_utf8(msg.to_vec()).unwrap();
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
|
||||
pub fn allow_foreground() -> Arc<AtomicBool> {
|
||||
let should_foreground = Arc::new(AtomicBool::new(false));
|
||||
let should_foreground_clone = should_foreground.clone();
|
||||
let _ = std::thread::spawn(move || loop {
|
||||
if !should_foreground_clone.load(Ordering::Relaxed) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
continue;
|
||||
}
|
||||
should_foreground_clone.store(false, Ordering::Relaxed);
|
||||
|
||||
for _ in 0..60 {
|
||||
desktop_core::biometric::windows_focus::focus_security_prompt();
|
||||
std::thread::sleep(std::time::Duration::from_millis(1000));
|
||||
}
|
||||
});
|
||||
|
||||
should_foreground
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "1.85.0"
|
||||
channel = "1.87.0"
|
||||
components = [ "rustfmt", "clippy" ]
|
||||
profile = "minimal"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@bitwarden/desktop",
|
||||
"description": "A secure and free password manager for all of your devices.",
|
||||
"version": "2025.11.3",
|
||||
"version": "2025.12.0",
|
||||
"keywords": [
|
||||
"bitwarden",
|
||||
"password",
|
||||
|
||||
@@ -32,8 +32,9 @@
|
||||
<string>/Library/Application Support/Microsoft Edge Beta/NativeMessagingHosts/</string>
|
||||
<string>/Library/Application Support/Microsoft Edge Dev/NativeMessagingHosts/</string>
|
||||
<string>/Library/Application Support/Microsoft Edge Canary/NativeMessagingHosts/</string>
|
||||
<string>/Library/Application Support/Vivaldi/NativeMessagingHosts/</string>
|
||||
<string>/Library/Application Support/Vivaldi/NativeMessagingHosts/</string>
|
||||
<string>/Library/Application Support/Zen/NativeMessagingHosts/</string>
|
||||
<string>/Library/Application Support/net.imput.helium</string>
|
||||
</array>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
|
||||
@@ -101,8 +101,7 @@
|
||||
supportsBiometric &&
|
||||
form.value.biometric &&
|
||||
isWindows &&
|
||||
(userHasMasterPassword || (form.value.pin && userHasPinSet)) &&
|
||||
isWindowsV2BiometricsEnabled
|
||||
(userHasMasterPassword || (form.value.pin && userHasPinSet))
|
||||
"
|
||||
>
|
||||
<div class="checkbox form-group-child">
|
||||
|
||||
@@ -302,7 +302,6 @@ describe("SettingsComponent", () => {
|
||||
describe("windows desktop", () => {
|
||||
beforeEach(() => {
|
||||
platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
|
||||
desktopBiometricsService.isWindowsV2BiometricsEnabled.mockResolvedValue(true);
|
||||
|
||||
// Recreate component to apply the correct device
|
||||
fixture = TestBed.createComponent(SettingsComponent);
|
||||
@@ -449,7 +448,6 @@ describe("SettingsComponent", () => {
|
||||
desktopBiometricsService.hasPersistentKey.mockResolvedValue(enrolled);
|
||||
|
||||
await component.ngOnInit();
|
||||
component.isWindowsV2BiometricsEnabled = true;
|
||||
component.isWindows = true;
|
||||
component.form.value.requireMasterPasswordOnAppRestart = true;
|
||||
component.userHasMasterPassword = false;
|
||||
@@ -558,7 +556,6 @@ describe("SettingsComponent", () => {
|
||||
desktopBiometricsService.hasPersistentKey.mockResolvedValue(false);
|
||||
|
||||
await component.ngOnInit();
|
||||
component.isWindowsV2BiometricsEnabled = true;
|
||||
component.isWindows = true;
|
||||
component.form.value.requireMasterPasswordOnAppRestart =
|
||||
requireMasterPasswordOnAppRestart;
|
||||
@@ -659,6 +656,7 @@ describe("SettingsComponent", () => {
|
||||
describe("windows test cases", () => {
|
||||
beforeEach(() => {
|
||||
platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
|
||||
keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey));
|
||||
component.isWindows = true;
|
||||
component.isLinux = false;
|
||||
|
||||
@@ -683,8 +681,6 @@ describe("SettingsComponent", () => {
|
||||
|
||||
describe("when windows v2 biometrics is enabled", () => {
|
||||
beforeEach(() => {
|
||||
component.isWindowsV2BiometricsEnabled = true;
|
||||
|
||||
keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey));
|
||||
});
|
||||
|
||||
|
||||
@@ -148,7 +148,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
userHasPinSet: boolean;
|
||||
|
||||
pinEnabled$: Observable<boolean> = of(true);
|
||||
isWindowsV2BiometricsEnabled: boolean = false;
|
||||
|
||||
consolidatedSessionTimeoutComponent$: Observable<boolean>;
|
||||
|
||||
@@ -297,8 +296,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
async ngOnInit() {
|
||||
this.vaultTimeoutOptions = await this.generateVaultTimeoutOptions();
|
||||
|
||||
this.isWindowsV2BiometricsEnabled = await this.biometricsService.isWindowsV2BiometricsEnabled();
|
||||
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
|
||||
// Autotype is for Windows initially
|
||||
@@ -621,7 +618,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
// On Windows if a user turned off PIN without having a MP and has biometrics + require MP/PIN on restart enabled.
|
||||
if (
|
||||
this.isWindows &&
|
||||
this.isWindowsV2BiometricsEnabled &&
|
||||
this.supportsBiometric &&
|
||||
this.form.value.requireMasterPasswordOnAppRestart &&
|
||||
this.form.value.biometric &&
|
||||
@@ -682,14 +678,12 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
this.form.controls.autoPromptBiometrics.setValue(false);
|
||||
await this.biometricStateService.setPromptAutomatically(false);
|
||||
|
||||
if (this.isWindowsV2BiometricsEnabled) {
|
||||
// If the user doesn't have a MP or PIN then they have to use biometrics on app restart.
|
||||
if (!this.userHasMasterPassword && !this.userHasPinSet) {
|
||||
// Allow biometric unlock on app restart so the user doesn't get into a bad state.
|
||||
await this.enrollPersistentBiometricIfNeeded(activeUserId);
|
||||
} else {
|
||||
this.form.controls.requireMasterPasswordOnAppRestart.setValue(true);
|
||||
}
|
||||
// If the user doesn't have a MP or PIN then they have to use biometrics on app restart.
|
||||
if (!this.userHasMasterPassword && !this.userHasPinSet) {
|
||||
// Allow biometric unlock on app restart so the user doesn't get into a bad state.
|
||||
await this.enrollPersistentBiometricIfNeeded(activeUserId);
|
||||
} else {
|
||||
this.form.controls.requireMasterPasswordOnAppRestart.setValue(true);
|
||||
}
|
||||
} else if (this.isLinux) {
|
||||
// Similar to Windows
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@bitwarden/angular/auth/guards";
|
||||
import { ChangePasswordComponent } from "@bitwarden/angular/auth/password-management/change-password";
|
||||
import { SetInitialPasswordComponent } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.component";
|
||||
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
|
||||
import {
|
||||
DevicesIcon,
|
||||
RegistrationUserAddIcon,
|
||||
@@ -39,15 +40,19 @@ import {
|
||||
TwoFactorAuthGuard,
|
||||
NewDeviceVerificationComponent,
|
||||
} from "@bitwarden/auth/angular";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components";
|
||||
import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui";
|
||||
|
||||
import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard";
|
||||
import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component";
|
||||
import { VaultV2Component } from "../vault/app/vault/vault-v2.component";
|
||||
import { VaultComponent } from "../vault/app/vault-v3/vault.component";
|
||||
|
||||
import { Fido2PlaceholderComponent } from "./components/fido2placeholder.component";
|
||||
import { DesktopLayoutComponent } from "./layout/desktop-layout.component";
|
||||
import { SendComponent } from "./tools/send/send.component";
|
||||
import { SendV2Component } from "./tools/send-v2/send-v2.component";
|
||||
|
||||
/**
|
||||
* Data properties acceptable for use in route objects in the desktop
|
||||
@@ -99,7 +104,10 @@ const routes: Routes = [
|
||||
{
|
||||
path: "vault",
|
||||
component: VaultV2Component,
|
||||
canActivate: [authGuard],
|
||||
canActivate: [
|
||||
authGuard,
|
||||
canAccessFeature(FeatureFlag.DesktopUiMigrationMilestone1, false, "new-vault", false),
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "send",
|
||||
@@ -325,6 +333,21 @@ const routes: Routes = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
component: DesktopLayoutComponent,
|
||||
canActivate: [authGuard],
|
||||
children: [
|
||||
{
|
||||
path: "new-vault",
|
||||
component: VaultComponent,
|
||||
},
|
||||
{
|
||||
path: "new-sends",
|
||||
component: SendV2Component,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
||||
10
apps/desktop/src/app/layout/desktop-layout.component.html
Normal file
10
apps/desktop/src/app/layout/desktop-layout.component.html
Normal file
@@ -0,0 +1,10 @@
|
||||
<bit-layout>
|
||||
<app-side-nav slot="side-nav">
|
||||
<bit-nav-logo [openIcon]="logo" route="." [label]="'passwordManager' | i18n"></bit-nav-logo>
|
||||
|
||||
<bit-nav-item icon="bwi-vault" [text]="'vault' | i18n" route="new-vault"></bit-nav-item>
|
||||
<bit-nav-item icon="bwi-send" [text]="'send' | i18n" route="new-sends"></bit-nav-item>
|
||||
</app-side-nav>
|
||||
|
||||
<router-outlet></router-outlet>
|
||||
</bit-layout>
|
||||
61
apps/desktop/src/app/layout/desktop-layout.component.spec.ts
Normal file
61
apps/desktop/src/app/layout/desktop-layout.component.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { RouterModule } from "@angular/router";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { NavigationModule } from "@bitwarden/components";
|
||||
|
||||
import { DesktopLayoutComponent } from "./desktop-layout.component";
|
||||
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: true,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
describe("DesktopLayoutComponent", () => {
|
||||
let component: DesktopLayoutComponent;
|
||||
let fixture: ComponentFixture<DesktopLayoutComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DesktopLayoutComponent, RouterModule.forRoot([]), NavigationModule],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: mock<I18nService>(),
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(DesktopLayoutComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("creates component", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders bit-layout component", () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const layoutElement = compiled.querySelector("bit-layout");
|
||||
|
||||
expect(layoutElement).toBeTruthy();
|
||||
});
|
||||
|
||||
it("supports content projection for side-nav", () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const ngContent = compiled.querySelectorAll("ng-content");
|
||||
|
||||
expect(ngContent).toBeTruthy();
|
||||
});
|
||||
});
|
||||
19
apps/desktop/src/app/layout/desktop-layout.component.ts
Normal file
19
apps/desktop/src/app/layout/desktop-layout.component.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { RouterModule } from "@angular/router";
|
||||
|
||||
import { PasswordManagerLogo } from "@bitwarden/assets/svg";
|
||||
import { LayoutComponent, NavigationModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { DesktopSideNavComponent } from "./desktop-side-nav.component";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-layout",
|
||||
imports: [RouterModule, I18nPipe, LayoutComponent, NavigationModule, DesktopSideNavComponent],
|
||||
templateUrl: "./desktop-layout.component.html",
|
||||
})
|
||||
export class DesktopLayoutComponent {
|
||||
protected readonly logo = PasswordManagerLogo;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<bit-side-nav [variant]="variant()">
|
||||
<ng-content></ng-content>
|
||||
</bit-side-nav>
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { NavigationModule } from "@bitwarden/components";
|
||||
|
||||
import { DesktopSideNavComponent } from "./desktop-side-nav.component";
|
||||
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: true,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
describe("DesktopSideNavComponent", () => {
|
||||
let component: DesktopSideNavComponent;
|
||||
let fixture: ComponentFixture<DesktopSideNavComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DesktopSideNavComponent, NavigationModule],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: mock<I18nService>(),
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(DesktopSideNavComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("creates component", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders bit-side-nav component", () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const sideNavElement = compiled.querySelector("bit-side-nav");
|
||||
|
||||
expect(sideNavElement).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses primary variant by default", () => {
|
||||
expect(component.variant()).toBe("primary");
|
||||
});
|
||||
|
||||
it("accepts variant input", () => {
|
||||
fixture.componentRef.setInput("variant", "secondary");
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.variant()).toBe("secondary");
|
||||
});
|
||||
|
||||
it.skip("passes variant to bit-side-nav", () => {
|
||||
fixture.componentRef.setInput("variant", "secondary");
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
const sideNavElement = compiled.querySelector("bit-side-nav");
|
||||
|
||||
expect(sideNavElement.getAttribute("ng-reflect-variant")).toBe("secondary");
|
||||
});
|
||||
});
|
||||
14
apps/desktop/src/app/layout/desktop-side-nav.component.ts
Normal file
14
apps/desktop/src/app/layout/desktop-side-nav.component.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
|
||||
|
||||
import { NavigationModule, SideNavVariant } from "@bitwarden/components";
|
||||
|
||||
@Component({
|
||||
selector: "app-side-nav",
|
||||
templateUrl: "desktop-side-nav.component.html",
|
||||
imports: [CommonModule, NavigationModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DesktopSideNavComponent {
|
||||
readonly variant = input<SideNavVariant>("primary");
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { DOCUMENT } from "@angular/common";
|
||||
import { Inject, Injectable } from "@angular/core";
|
||||
import { Inject, Injectable, DOCUMENT } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
|
||||
|
||||
110
apps/desktop/src/app/tools/send-v2/send-v2.component.html
Normal file
110
apps/desktop/src/app/tools/send-v2/send-v2.component.html
Normal file
@@ -0,0 +1,110 @@
|
||||
<div id="sends" class="vault">
|
||||
<div id="items" class="items">
|
||||
<div class="content">
|
||||
<div class="list full-height" *ngIf="filteredSends && filteredSends.length">
|
||||
<button
|
||||
type="button"
|
||||
*ngFor="let s of filteredSends"
|
||||
appStopClick
|
||||
(click)="selectSend(s.id)"
|
||||
title="{{ 'viewItem' | i18n }}"
|
||||
(contextmenu)="viewSendMenu(s)"
|
||||
[ngClass]="{ active: s.id === sendId }"
|
||||
[attr.aria-pressed]="s.id === sendId"
|
||||
class="flex-list-item"
|
||||
>
|
||||
<span class="item-icon" aria-hidden="true">
|
||||
<i class="bwi bwi-fw bwi-lg" [ngClass]="s.type == 0 ? 'bwi-file-text' : 'bwi-file'"></i>
|
||||
</span>
|
||||
<span class="item-content">
|
||||
<span class="item-title">
|
||||
{{ s.name }}
|
||||
<span class="title-badges">
|
||||
<ng-container *ngIf="s.disabled">
|
||||
<i
|
||||
class="bwi bwi-exclamation-triangle"
|
||||
appStopProp
|
||||
title="{{ 'disabled' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="sr-only">{{ "disabled" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="s.password">
|
||||
<i
|
||||
class="bwi bwi-key"
|
||||
appStopProp
|
||||
title="{{ 'password' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="sr-only">{{ "password" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="s.maxAccessCountReached">
|
||||
<i
|
||||
class="bwi bwi-exclamation-triangle"
|
||||
appStopProp
|
||||
title="{{ 'maxAccessCountReached' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="sr-only">{{ "maxAccessCountReached" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="s.expired">
|
||||
<i
|
||||
class="bwi bwi-clock"
|
||||
appStopProp
|
||||
title="{{ 'expired' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="sr-only">{{ "expired" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="s.pendingDelete">
|
||||
<i
|
||||
class="bwi bwi-trash"
|
||||
appStopProp
|
||||
title="{{ 'pendingDeletion' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="sr-only">{{ "pendingDeletion" | i18n }}</span>
|
||||
</ng-container>
|
||||
</span>
|
||||
</span>
|
||||
<span class="item-details">{{ s.deletionDate | date }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="no-items" *ngIf="!filteredSends || !filteredSends.length">
|
||||
<i class="bwi bwi-spinner bwi-spin bwi-3x" *ngIf="!loaded" aria-hidden="true"></i>
|
||||
<ng-container *ngIf="loaded">
|
||||
<img class="no-items-image" aria-hidden="true" />
|
||||
<p>{{ "noItemsInList" | i18n }}</p>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<button
|
||||
type="button"
|
||||
(click)="addSend()"
|
||||
class="block primary"
|
||||
appA11yTitle="{{ 'addItem' | i18n }}"
|
||||
>
|
||||
<i class="bwi bwi-plus bwi-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<app-send-add-edit
|
||||
id="addEdit"
|
||||
class="details"
|
||||
*ngIf="action == 'add' || action == 'edit'"
|
||||
[sendId]="sendId"
|
||||
[type]="selectedSendType"
|
||||
(onSavedSend)="savedSend($event)"
|
||||
(onCancelled)="cancel($event)"
|
||||
(onDeletedSend)="deletedSend($event)"
|
||||
></app-send-add-edit>
|
||||
<div class="logo" *ngIf="!action">
|
||||
<div class="content">
|
||||
<div class="inner-content">
|
||||
<img class="logo-image" alt="Bitwarden" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
364
apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts
Normal file
364
apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
|
||||
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import * as utils from "../../../utils";
|
||||
import { SearchBarService } from "../../layout/search/search-bar.service";
|
||||
import { AddEditComponent } from "../send/add-edit.component";
|
||||
|
||||
import { SendV2Component } from "./send-v2.component";
|
||||
|
||||
// Mock the invokeMenu utility function
|
||||
jest.mock("../../../utils", () => ({
|
||||
invokeMenu: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("SendV2Component", () => {
|
||||
let component: SendV2Component;
|
||||
let fixture: ComponentFixture<SendV2Component>;
|
||||
let sendService: MockProxy<SendService>;
|
||||
let searchBarService: MockProxy<SearchBarService>;
|
||||
let broadcasterService: MockProxy<BroadcasterService>;
|
||||
let accountService: MockProxy<AccountService>;
|
||||
let policyService: MockProxy<PolicyService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
sendService = mock<SendService>();
|
||||
searchBarService = mock<SearchBarService>();
|
||||
broadcasterService = mock<BroadcasterService>();
|
||||
accountService = mock<AccountService>();
|
||||
policyService = mock<PolicyService>();
|
||||
|
||||
// Mock sendViews$ observable
|
||||
sendService.sendViews$ = of([]);
|
||||
searchBarService.searchText$ = new BehaviorSubject<string>("");
|
||||
|
||||
// Mock activeAccount$ observable for parent class ngOnInit
|
||||
accountService.activeAccount$ = of({ id: "test-user-id" } as any);
|
||||
policyService.policyAppliesToUser$ = jest.fn().mockReturnValue(of(false));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SendV2Component],
|
||||
providers: [
|
||||
{ provide: SendService, useValue: sendService },
|
||||
{ provide: I18nService, useValue: mock<I18nService>() },
|
||||
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
|
||||
{ provide: EnvironmentService, useValue: mock<EnvironmentService>() },
|
||||
{ provide: BroadcasterService, useValue: broadcasterService },
|
||||
{ provide: SearchService, useValue: mock<SearchService>() },
|
||||
{ provide: PolicyService, useValue: policyService },
|
||||
{ provide: SearchBarService, useValue: searchBarService },
|
||||
{ provide: LogService, useValue: mock<LogService>() },
|
||||
{ provide: SendApiService, useValue: mock<SendApiService>() },
|
||||
{ provide: DialogService, useValue: mock<DialogService>() },
|
||||
{ provide: ToastService, useValue: mock<ToastService>() },
|
||||
{ provide: AccountService, useValue: accountService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SendV2Component);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it("creates component", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it("initializes with correct default action", () => {
|
||||
expect(component.action).toBe("");
|
||||
});
|
||||
|
||||
it("subscribes to broadcaster service on init", async () => {
|
||||
await component.ngOnInit();
|
||||
expect(broadcasterService.subscribe).toHaveBeenCalledWith(
|
||||
"SendV2Component",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("unsubscribes from broadcaster service on destroy", () => {
|
||||
component.ngOnDestroy();
|
||||
expect(broadcasterService.unsubscribe).toHaveBeenCalledWith("SendV2Component");
|
||||
});
|
||||
|
||||
it("enables search bar on init", async () => {
|
||||
await component.ngOnInit();
|
||||
expect(searchBarService.setEnabled).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("disables search bar on destroy", () => {
|
||||
component.ngOnDestroy();
|
||||
expect(searchBarService.setEnabled).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
describe("addSend", () => {
|
||||
it("sets action to Add", async () => {
|
||||
await component.addSend();
|
||||
expect(component.action).toBe("add");
|
||||
});
|
||||
|
||||
it("calls resetAndLoad on addEditComponent when component exists", async () => {
|
||||
const mockAddEdit = mock<AddEditComponent>();
|
||||
component.addEditComponent = mockAddEdit;
|
||||
|
||||
await component.addSend();
|
||||
|
||||
expect(mockAddEdit.resetAndLoad).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not throw when addEditComponent is null", async () => {
|
||||
component.addEditComponent = null;
|
||||
await expect(component.addSend()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancel", () => {
|
||||
it("resets action to None", () => {
|
||||
component.action = "edit";
|
||||
component.sendId = "test-id";
|
||||
|
||||
component.cancel(new SendView());
|
||||
|
||||
expect(component.action).toBe("");
|
||||
expect(component.sendId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deletedSend", () => {
|
||||
it("refreshes the list and resets action and sendId", async () => {
|
||||
component.action = "edit";
|
||||
component.sendId = "test-id";
|
||||
jest.spyOn(component, "refresh").mockResolvedValue();
|
||||
|
||||
const mockSend = new SendView();
|
||||
await component.deletedSend(mockSend);
|
||||
|
||||
expect(component.refresh).toHaveBeenCalled();
|
||||
expect(component.action).toBe("");
|
||||
expect(component.sendId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("savedSend", () => {
|
||||
it("refreshes the list and selects the saved send", async () => {
|
||||
jest.spyOn(component, "refresh").mockResolvedValue();
|
||||
jest.spyOn(component, "selectSend").mockResolvedValue();
|
||||
|
||||
const mockSend = new SendView();
|
||||
mockSend.id = "saved-send-id";
|
||||
|
||||
await component.savedSend(mockSend);
|
||||
|
||||
expect(component.refresh).toHaveBeenCalled();
|
||||
expect(component.selectSend).toHaveBeenCalledWith("saved-send-id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectSend", () => {
|
||||
it("sets action to Edit and updates sendId", async () => {
|
||||
await component.selectSend("new-send-id");
|
||||
|
||||
expect(component.action).toBe("edit");
|
||||
expect(component.sendId).toBe("new-send-id");
|
||||
});
|
||||
|
||||
it("updates addEditComponent when it exists", async () => {
|
||||
const mockAddEdit = mock<AddEditComponent>();
|
||||
component.addEditComponent = mockAddEdit;
|
||||
|
||||
await component.selectSend("test-send-id");
|
||||
|
||||
expect(mockAddEdit.sendId).toBe("test-send-id");
|
||||
expect(mockAddEdit.refresh).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reload if same send is already selected in edit mode", async () => {
|
||||
const mockAddEdit = mock<AddEditComponent>();
|
||||
component.addEditComponent = mockAddEdit;
|
||||
component.sendId = "same-id";
|
||||
component.action = "edit";
|
||||
|
||||
await component.selectSend("same-id");
|
||||
|
||||
expect(mockAddEdit.refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reloads if selecting different send", async () => {
|
||||
const mockAddEdit = mock<AddEditComponent>();
|
||||
component.addEditComponent = mockAddEdit;
|
||||
component.sendId = "old-id";
|
||||
component.action = "edit";
|
||||
|
||||
await component.selectSend("new-id");
|
||||
|
||||
expect(mockAddEdit.refresh).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectedSendType", () => {
|
||||
it("returns the type of the currently selected send", () => {
|
||||
const mockSend1 = new SendView();
|
||||
mockSend1.id = "send-1";
|
||||
mockSend1.type = SendType.Text;
|
||||
|
||||
const mockSend2 = new SendView();
|
||||
mockSend2.id = "send-2";
|
||||
mockSend2.type = SendType.File;
|
||||
|
||||
component.sends = [mockSend1, mockSend2];
|
||||
component.sendId = "send-2";
|
||||
|
||||
expect(component.selectedSendType).toBe(SendType.File);
|
||||
});
|
||||
|
||||
it("returns undefined when no send is selected", () => {
|
||||
component.sends = [];
|
||||
component.sendId = "non-existent";
|
||||
|
||||
expect(component.selectedSendType).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when sendId is null", () => {
|
||||
const mockSend = new SendView();
|
||||
mockSend.id = "send-1";
|
||||
mockSend.type = SendType.Text;
|
||||
|
||||
component.sends = [mockSend];
|
||||
component.sendId = null;
|
||||
|
||||
expect(component.selectedSendType).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("viewSendMenu", () => {
|
||||
let mockSend: SendView;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSend = new SendView();
|
||||
mockSend.id = "test-send";
|
||||
mockSend.name = "Test Send";
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("creates menu with copy link option", () => {
|
||||
jest.spyOn(component, "copy").mockResolvedValue();
|
||||
|
||||
component.viewSendMenu(mockSend);
|
||||
|
||||
expect(utils.invokeMenu).toHaveBeenCalled();
|
||||
const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0];
|
||||
expect(menuItems.length).toBeGreaterThanOrEqual(2); // At minimum: copy link + delete
|
||||
});
|
||||
|
||||
it("includes remove password option when send has password and is not disabled", () => {
|
||||
mockSend.password = "test-password";
|
||||
mockSend.disabled = false;
|
||||
jest.spyOn(component, "removePassword").mockResolvedValue(true);
|
||||
|
||||
component.viewSendMenu(mockSend);
|
||||
|
||||
expect(utils.invokeMenu).toHaveBeenCalled();
|
||||
const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0];
|
||||
expect(menuItems.length).toBe(3); // copy link + remove password + delete
|
||||
});
|
||||
|
||||
it("excludes remove password option when send has no password", () => {
|
||||
mockSend.password = null;
|
||||
mockSend.disabled = false;
|
||||
|
||||
component.viewSendMenu(mockSend);
|
||||
|
||||
expect(utils.invokeMenu).toHaveBeenCalled();
|
||||
const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0];
|
||||
expect(menuItems.length).toBe(2); // copy link + delete (no remove password)
|
||||
});
|
||||
|
||||
it("excludes remove password option when send is disabled", () => {
|
||||
mockSend.password = "test-password";
|
||||
mockSend.disabled = true;
|
||||
|
||||
component.viewSendMenu(mockSend);
|
||||
|
||||
expect(utils.invokeMenu).toHaveBeenCalled();
|
||||
const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0];
|
||||
expect(menuItems.length).toBe(2); // copy link + delete (no remove password)
|
||||
});
|
||||
|
||||
it("always includes delete option", () => {
|
||||
jest.spyOn(component, "delete").mockResolvedValue(true);
|
||||
jest.spyOn(component, "deletedSend").mockResolvedValue();
|
||||
|
||||
component.viewSendMenu(mockSend);
|
||||
|
||||
expect(utils.invokeMenu).toHaveBeenCalled();
|
||||
const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0];
|
||||
// Delete is always the last item in the menu
|
||||
expect(menuItems.length).toBeGreaterThan(0);
|
||||
expect(menuItems[menuItems.length - 1]).toHaveProperty("label");
|
||||
expect(menuItems[menuItems.length - 1]).toHaveProperty("click");
|
||||
});
|
||||
});
|
||||
|
||||
describe("search bar subscription", () => {
|
||||
it("updates searchText when search bar text changes", () => {
|
||||
const searchSubject = new BehaviorSubject<string>("initial");
|
||||
searchBarService.searchText$ = searchSubject;
|
||||
|
||||
// Create new component to trigger constructor subscription
|
||||
fixture = TestBed.createComponent(SendV2Component);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
searchSubject.next("new search text");
|
||||
|
||||
expect(component.searchText).toBe("new search text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("load", () => {
|
||||
it("sets loading states correctly", async () => {
|
||||
jest.spyOn(component, "search").mockResolvedValue();
|
||||
jest.spyOn(component, "selectAll");
|
||||
|
||||
expect(component.loaded).toBeFalsy();
|
||||
|
||||
await component.load();
|
||||
|
||||
expect(component.loading).toBe(false);
|
||||
expect(component.loaded).toBe(true);
|
||||
});
|
||||
|
||||
it("calls selectAll when onSuccessfulLoad is not set", async () => {
|
||||
jest.spyOn(component, "search").mockResolvedValue();
|
||||
jest.spyOn(component, "selectAll");
|
||||
component.onSuccessfulLoad = null;
|
||||
|
||||
await component.load();
|
||||
|
||||
expect(component.selectAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onSuccessfulLoad when it is set", async () => {
|
||||
jest.spyOn(component, "search").mockResolvedValue();
|
||||
const mockCallback = jest.fn().mockResolvedValue(undefined);
|
||||
component.onSuccessfulLoad = mockCallback;
|
||||
|
||||
await component.load();
|
||||
|
||||
expect(mockCallback).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
233
apps/desktop/src/app/tools/send-v2/send-v2.component.ts
Normal file
233
apps/desktop/src/app/tools/send-v2/send-v2.component.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnInit, OnDestroy, ViewChild, NgZone, ChangeDetectorRef } from "@angular/core";
|
||||
import { FormsModule } from "@angular/forms";
|
||||
import { mergeMap } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { SendComponent as BaseSendComponent } from "@bitwarden/angular/tools/send/send.component";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { invokeMenu, RendererMenuItem } from "../../../utils";
|
||||
import { SearchBarService } from "../../layout/search/search-bar.service";
|
||||
import { AddEditComponent } from "../send/add-edit.component";
|
||||
|
||||
const Action = Object.freeze({
|
||||
/** No action is currently active. */
|
||||
None: "",
|
||||
/** The user is adding a new Send. */
|
||||
Add: "add",
|
||||
/** The user is editing an existing Send. */
|
||||
Edit: "edit",
|
||||
} as const);
|
||||
|
||||
type Action = (typeof Action)[keyof typeof Action];
|
||||
|
||||
const BroadcasterSubscriptionId = "SendV2Component";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-send-v2",
|
||||
imports: [CommonModule, JslibModule, FormsModule, AddEditComponent],
|
||||
templateUrl: "./send-v2.component.html",
|
||||
})
|
||||
export class SendV2Component extends BaseSendComponent implements OnInit, OnDestroy {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@ViewChild(AddEditComponent) addEditComponent: AddEditComponent;
|
||||
|
||||
// The ID of the currently selected Send item being viewed or edited
|
||||
sendId: string;
|
||||
|
||||
// Tracks the current UI state: viewing list (None), adding new Send (Add), or editing existing Send (Edit)
|
||||
action: Action = Action.None;
|
||||
|
||||
constructor(
|
||||
sendService: SendService,
|
||||
i18nService: I18nService,
|
||||
platformUtilsService: PlatformUtilsService,
|
||||
environmentService: EnvironmentService,
|
||||
private broadcasterService: BroadcasterService,
|
||||
ngZone: NgZone,
|
||||
searchService: SearchService,
|
||||
policyService: PolicyService,
|
||||
private searchBarService: SearchBarService,
|
||||
logService: LogService,
|
||||
sendApiService: SendApiService,
|
||||
dialogService: DialogService,
|
||||
toastService: ToastService,
|
||||
accountService: AccountService,
|
||||
private cdr: ChangeDetectorRef,
|
||||
) {
|
||||
super(
|
||||
sendService,
|
||||
i18nService,
|
||||
platformUtilsService,
|
||||
environmentService,
|
||||
ngZone,
|
||||
searchService,
|
||||
policyService,
|
||||
logService,
|
||||
sendApiService,
|
||||
dialogService,
|
||||
toastService,
|
||||
accountService,
|
||||
);
|
||||
|
||||
// Listen to search bar changes and update the Send list filter
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||
this.searchBarService.searchText$.subscribe((searchText) => {
|
||||
this.searchText = searchText;
|
||||
this.searchTextChanged();
|
||||
setTimeout(() => this.cdr.detectChanges(), 250);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize the component: enable search bar, subscribe to sync events, and load Send items
|
||||
async ngOnInit() {
|
||||
this.searchBarService.setEnabled(true);
|
||||
this.searchBarService.setPlaceholderText(this.i18nService.t("searchSends"));
|
||||
|
||||
await super.ngOnInit();
|
||||
|
||||
// Listen for sync completion events to refresh the Send list
|
||||
this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => {
|
||||
// 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.ngZone.run(async () => {
|
||||
switch (message.command) {
|
||||
case "syncCompleted":
|
||||
await this.load();
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
await this.load();
|
||||
}
|
||||
|
||||
// Clean up subscriptions and disable search bar when component is destroyed
|
||||
ngOnDestroy() {
|
||||
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
|
||||
this.searchBarService.setEnabled(false);
|
||||
}
|
||||
|
||||
// Load Send items from the service and display them in the list.
|
||||
// Subscribes to sendViews$ observable to get updates when Sends change.
|
||||
// Manually triggers change detection to ensure UI updates immediately.
|
||||
// Note: The filter parameter is ignored in this implementation for desktop-specific behavior.
|
||||
async load(filter: (send: SendView) => boolean = null) {
|
||||
this.loading = true;
|
||||
this.sendService.sendViews$
|
||||
.pipe(
|
||||
mergeMap(async (sends) => {
|
||||
this.sends = sends;
|
||||
await this.search(null);
|
||||
// Trigger change detection after data updates
|
||||
this.cdr.detectChanges();
|
||||
}),
|
||||
)
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||
.subscribe();
|
||||
if (this.onSuccessfulLoad != null) {
|
||||
await this.onSuccessfulLoad();
|
||||
} else {
|
||||
// Default action
|
||||
this.selectAll();
|
||||
}
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
// Open the add Send form to create a new Send item
|
||||
async addSend() {
|
||||
this.action = Action.Add;
|
||||
if (this.addEditComponent != null) {
|
||||
await this.addEditComponent.resetAndLoad();
|
||||
}
|
||||
}
|
||||
|
||||
// Close the add/edit form and return to the list view
|
||||
cancel(s: SendView) {
|
||||
this.action = Action.None;
|
||||
this.sendId = null;
|
||||
}
|
||||
|
||||
// Handle when a Send is deleted: refresh the list and close the edit form
|
||||
async deletedSend(s: SendView) {
|
||||
await this.refresh();
|
||||
this.action = Action.None;
|
||||
this.sendId = null;
|
||||
}
|
||||
|
||||
// Handle when a Send is saved: refresh the list and re-select the saved Send
|
||||
async savedSend(s: SendView) {
|
||||
await this.refresh();
|
||||
// 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.selectSend(s.id);
|
||||
}
|
||||
|
||||
// Select a Send from the list and open it in the edit form.
|
||||
// If the same Send is already selected and in edit mode, do nothing to avoid unnecessary reloads.
|
||||
async selectSend(sendId: string) {
|
||||
if (sendId === this.sendId && this.action === Action.Edit) {
|
||||
return;
|
||||
}
|
||||
this.action = Action.Edit;
|
||||
this.sendId = sendId;
|
||||
if (this.addEditComponent != null) {
|
||||
this.addEditComponent.sendId = sendId;
|
||||
await this.addEditComponent.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
// Get the type (text or file) of the currently selected Send for the edit form
|
||||
get selectedSendType() {
|
||||
return this.sends.find((s) => s.id === this.sendId)?.type;
|
||||
}
|
||||
|
||||
// Show the right-click context menu for a Send with options to copy link, remove password, or delete
|
||||
viewSendMenu(send: SendView) {
|
||||
const menu: RendererMenuItem[] = [];
|
||||
menu.push({
|
||||
label: this.i18nService.t("copyLink"),
|
||||
click: () => this.copy(send),
|
||||
});
|
||||
if (send.password && !send.disabled) {
|
||||
menu.push({
|
||||
label: this.i18nService.t("removePassword"),
|
||||
click: async () => {
|
||||
await this.removePassword(send);
|
||||
if (this.sendId === send.id) {
|
||||
this.sendId = 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
|
||||
this.selectSend(send.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
menu.push({
|
||||
label: this.i18nService.t("delete"),
|
||||
click: async () => {
|
||||
await this.delete(send);
|
||||
await this.deletedSend(send);
|
||||
},
|
||||
});
|
||||
|
||||
invokeMenu(menu);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user