mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
Merge branch 'main' into PM-26250-Explore-options-to-enable-direct-importer-for-mac-app-store-build
This commit is contained in:
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",
|
||||
],
|
||||
|
||||
14
.github/workflows/build-desktop.yml
vendored
14
.github/workflows/build-desktop.yml
vendored
@@ -186,7 +186,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
|
||||
@@ -342,7 +342,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
|
||||
@@ -490,7 +490,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 +756,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 +1007,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 +1244,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 +1516,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
|
||||
}
|
||||
});
|
||||
|
||||
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",
|
||||
|
||||
@@ -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!"
|
||||
|
||||
@@ -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": "您没有查看此页面的权限。请尝试使用其他账户登录。"
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ describe("Fido2Background", () => {
|
||||
{ file: Fido2ContentScript.PageScriptDelayAppend },
|
||||
{ file: Fido2ContentScript.ContentScript },
|
||||
],
|
||||
world: "MAIN",
|
||||
world: "ISOLATED",
|
||||
...sharedRegistrationOptions,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,7 +176,7 @@ export class Fido2Background implements Fido2BackgroundInterface {
|
||||
{ file: await this.getFido2PageScriptAppendFileName() },
|
||||
{ file: Fido2ContentScript.ContentScript },
|
||||
],
|
||||
world: "MAIN",
|
||||
world: "ISOLATED",
|
||||
...this.sharedRegistrationOptions,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,38 +29,48 @@ describe("FIDO2 page-script for manifest v2", () => {
|
||||
expect(window.document.createElement).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("appends the `page-script.js` file to the document head when the contentType is `text/html`", () => {
|
||||
it("appends the `page-script.js` file to the document head when the contentType is `text/html`", async () => {
|
||||
const scriptContents = "test-script-contents";
|
||||
jest.spyOn(window.document.head, "prepend").mockImplementation((node) => {
|
||||
createdScriptElement = node as HTMLScriptElement;
|
||||
return node;
|
||||
});
|
||||
window.fetch = jest.fn().mockResolvedValue({
|
||||
text: () => Promise.resolve(scriptContents),
|
||||
} as Response);
|
||||
|
||||
// FIXME: Remove when updating file. Eslint update
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require("./fido2-page-script-delay-append.mv2.ts");
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
expect(window.document.createElement).toHaveBeenCalledWith("script");
|
||||
expect(chrome.runtime.getURL).toHaveBeenCalledWith(Fido2ContentScript.PageScript);
|
||||
expect(window.document.head.prepend).toHaveBeenCalledWith(expect.any(HTMLScriptElement));
|
||||
expect(createdScriptElement.src).toBe(`chrome-extension://id/${Fido2ContentScript.PageScript}`);
|
||||
expect(createdScriptElement.innerHTML).toBe(scriptContents);
|
||||
});
|
||||
|
||||
it("appends the `page-script.js` file to the document element if the head is not available", () => {
|
||||
it("appends the `page-script.js` file to the document element if the head is not available", async () => {
|
||||
const scriptContents = "test-script-contents";
|
||||
window.document.documentElement.removeChild(window.document.head);
|
||||
jest.spyOn(window.document.documentElement, "prepend").mockImplementation((node) => {
|
||||
createdScriptElement = node as HTMLScriptElement;
|
||||
return node;
|
||||
});
|
||||
window.fetch = jest.fn().mockResolvedValue({
|
||||
text: () => Promise.resolve(scriptContents),
|
||||
} as Response);
|
||||
|
||||
// FIXME: Remove when updating file. Eslint update
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require("./fido2-page-script-delay-append.mv2.ts");
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
expect(window.document.createElement).toHaveBeenCalledWith("script");
|
||||
expect(chrome.runtime.getURL).toHaveBeenCalledWith(Fido2ContentScript.PageScript);
|
||||
expect(window.document.documentElement.prepend).toHaveBeenCalledWith(
|
||||
expect.any(HTMLScriptElement),
|
||||
);
|
||||
expect(createdScriptElement.src).toBe(`chrome-extension://id/${Fido2ContentScript.PageScript}`);
|
||||
expect(createdScriptElement.innerHTML).toBe(scriptContents);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,18 +2,26 @@
|
||||
* This script handles injection of the FIDO2 override page script into the document.
|
||||
* This is required for manifest v2, but will be removed when we migrate fully to manifest v3.
|
||||
*/
|
||||
(function (globalContext) {
|
||||
void (async function (globalContext) {
|
||||
if (globalContext.document.contentType !== "text/html") {
|
||||
return;
|
||||
}
|
||||
|
||||
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.
|
||||
// eslint-disable-next-line @bitwarden/platform/no-page-script-url-leakage
|
||||
script.src = chrome.runtime.getURL("content/fido2-page-script.js");
|
||||
script.async = false;
|
||||
|
||||
const pageScriptUrl = chrome.runtime.getURL("content/fido2-page-script.js");
|
||||
// Inject the script contents directly to avoid leaking the extension URL
|
||||
try {
|
||||
const response = await fetch(pageScriptUrl);
|
||||
const scriptContents = await response.text();
|
||||
script.innerHTML = scriptContents;
|
||||
} catch {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to load FIDO2 page script contents. Injection failed.");
|
||||
return;
|
||||
}
|
||||
|
||||
// We are ensuring that the script injection is delayed in the event that we are loading
|
||||
// within an iframe element. This prevents an issue with web mail clients that load content
|
||||
// using ajax within iframes. In particular, Zimbra web mail client was observed to have this issue.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -277,6 +277,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) {
|
||||
|
||||
@@ -518,7 +518,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,
|
||||
|
||||
8
apps/desktop/desktop_native/Cargo.lock
generated
8
apps/desktop/desktop_native/Cargo.lock
generated
@@ -1864,9 +1864,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",
|
||||
@@ -1878,9 +1878,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +148,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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +184,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));
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
18
apps/desktop/src/app/layout/desktop-layout.component.ts
Normal file
18
apps/desktop/src/app/layout/desktop-layout.component.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ChangeDetectionStrategy, 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";
|
||||
|
||||
@Component({
|
||||
selector: "app-layout",
|
||||
imports: [RouterModule, I18nPipe, LayoutComponent, NavigationModule, DesktopSideNavComponent],
|
||||
templateUrl: "./desktop-layout.component.html",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
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";
|
||||
|
||||
22
apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts
Normal file
22
apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
|
||||
import { SendV2Component } from "./send-v2.component";
|
||||
|
||||
describe("SendV2Component", () => {
|
||||
let component: SendV2Component;
|
||||
let fixture: ComponentFixture<SendV2Component>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SendV2Component],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SendV2Component);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("creates component", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
9
apps/desktop/src/app/tools/send-v2/send-v2.component.ts
Normal file
9
apps/desktop/src/app/tools/send-v2/send-v2.component.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Component, ChangeDetectionStrategy } from "@angular/core";
|
||||
|
||||
@Component({
|
||||
selector: "app-send-v2",
|
||||
imports: [],
|
||||
template: "<p>Sends V2 Component</p>",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SendV2Component {}
|
||||
@@ -16,9 +16,6 @@ export abstract class DesktopBiometricsService extends BiometricsService {
|
||||
abstract enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise<void>;
|
||||
abstract hasPersistentKey(userId: UserId): Promise<boolean>;
|
||||
/* Enables the v2 biometrics re-write. This will stay enabled until the application is restarted. */
|
||||
abstract enableWindowsV2Biometrics(): Promise<void>;
|
||||
abstract isWindowsV2BiometricsEnabled(): Promise<boolean>;
|
||||
/* Enables the v2 biometrics re-write. This will stay enabled until the application is restarted. */
|
||||
abstract enableLinuxV2Biometrics(): Promise<void>;
|
||||
abstract isLinuxV2BiometricsEnabled(): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -58,10 +58,6 @@ export class MainBiometricsIPCListener {
|
||||
message.userId as UserId,
|
||||
SymmetricCryptoKey.fromString(message.key as string),
|
||||
);
|
||||
case BiometricAction.EnableWindowsV2:
|
||||
return await this.biometricService.enableWindowsV2Biometrics();
|
||||
case BiometricAction.IsWindowsV2Enabled:
|
||||
return await this.biometricService.isWindowsV2BiometricsEnabled();
|
||||
case BiometricAction.EnableLinuxV2:
|
||||
return await this.biometricService.enableLinuxV2Biometrics();
|
||||
case BiometricAction.IsLinuxV2Enabled:
|
||||
|
||||
@@ -20,7 +20,6 @@ import { MainBiometricsService } from "./main-biometrics.service";
|
||||
import { WindowsBiometricsSystem } from "./native-v2";
|
||||
import OsBiometricsServiceLinux from "./os-biometrics-linux.service";
|
||||
import OsBiometricsServiceMac from "./os-biometrics-mac.service";
|
||||
import OsBiometricsServiceWindows from "./os-biometrics-windows.service";
|
||||
import { OsBiometricService } from "./os-biometrics.service";
|
||||
|
||||
jest.mock("@bitwarden/desktop-napi", () => {
|
||||
@@ -61,7 +60,7 @@ describe("MainBiometricsService", function () {
|
||||
|
||||
const internalService = (sut as any).osBiometricsService;
|
||||
expect(internalService).not.toBeNull();
|
||||
expect(internalService).toBeInstanceOf(OsBiometricsServiceWindows);
|
||||
expect(internalService).toBeInstanceOf(WindowsBiometricsSystem);
|
||||
});
|
||||
|
||||
it("Should create a biometrics service specific for MacOs", () => {
|
||||
@@ -289,78 +288,6 @@ describe("MainBiometricsService", function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe("enableWindowsV2Biometrics", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("enables Windows V2 biometrics when platform is win32 and not already enabled", async () => {
|
||||
const sut = new MainBiometricsService(
|
||||
i18nService,
|
||||
windowMain,
|
||||
logService,
|
||||
"win32",
|
||||
biometricStateService,
|
||||
encryptService,
|
||||
cryptoFunctionService,
|
||||
);
|
||||
|
||||
await sut.enableWindowsV2Biometrics();
|
||||
|
||||
expect(logService.info).toHaveBeenCalledWith(
|
||||
"[BiometricsMain] Loading native biometrics module v2 for windows",
|
||||
);
|
||||
expect(await sut.isWindowsV2BiometricsEnabled()).toBe(true);
|
||||
const internalService = (sut as any).osBiometricsService;
|
||||
expect(internalService).not.toBeNull();
|
||||
expect(internalService).toBeInstanceOf(WindowsBiometricsSystem);
|
||||
});
|
||||
|
||||
it("should not enable Windows V2 biometrics when platform is not win32", async () => {
|
||||
const sut = new MainBiometricsService(
|
||||
i18nService,
|
||||
windowMain,
|
||||
logService,
|
||||
"darwin",
|
||||
biometricStateService,
|
||||
encryptService,
|
||||
cryptoFunctionService,
|
||||
);
|
||||
|
||||
await sut.enableWindowsV2Biometrics();
|
||||
|
||||
expect(logService.info).not.toHaveBeenCalled();
|
||||
expect(await sut.isWindowsV2BiometricsEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("should not enable Windows V2 biometrics when already enabled", async () => {
|
||||
const sut = new MainBiometricsService(
|
||||
i18nService,
|
||||
windowMain,
|
||||
logService,
|
||||
"win32",
|
||||
biometricStateService,
|
||||
encryptService,
|
||||
cryptoFunctionService,
|
||||
);
|
||||
|
||||
// Enable it first
|
||||
await sut.enableWindowsV2Biometrics();
|
||||
|
||||
// Enable it again
|
||||
await sut.enableWindowsV2Biometrics();
|
||||
|
||||
expect(logService.info).toHaveBeenCalledWith(
|
||||
"[BiometricsMain] Loading native biometrics module v2 for windows",
|
||||
);
|
||||
expect(logService.info).toHaveBeenCalledTimes(1);
|
||||
expect(await sut.isWindowsV2BiometricsEnabled()).toBe(true);
|
||||
const internalService = (sut as any).osBiometricsService;
|
||||
expect(internalService).not.toBeNull();
|
||||
expect(internalService).toBeInstanceOf(WindowsBiometricsSystem);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pass through methods that call platform specific osBiometricsService methods", () => {
|
||||
const userId = newGuid() as UserId;
|
||||
let sut: MainBiometricsService;
|
||||
|
||||
@@ -16,7 +16,6 @@ import { OsBiometricService } from "./os-biometrics.service";
|
||||
export class MainBiometricsService extends DesktopBiometricsService {
|
||||
private osBiometricsService: OsBiometricService;
|
||||
private shouldAutoPrompt = true;
|
||||
private windowsV2BiometricsEnabled = false;
|
||||
private linuxV2BiometricsEnabled = false;
|
||||
|
||||
constructor(
|
||||
@@ -30,15 +29,10 @@ export class MainBiometricsService extends DesktopBiometricsService {
|
||||
) {
|
||||
super();
|
||||
if (platform === "win32") {
|
||||
// eslint-disable-next-line
|
||||
const OsBiometricsServiceWindows = require("./os-biometrics-windows.service").default;
|
||||
this.osBiometricsService = new OsBiometricsServiceWindows(
|
||||
this.osBiometricsService = new WindowsBiometricsSystem(
|
||||
this.i18nService,
|
||||
this.windowMain,
|
||||
this.logService,
|
||||
this.biometricStateService,
|
||||
this.encryptService,
|
||||
this.cryptoFunctionService,
|
||||
);
|
||||
} else if (platform === "darwin") {
|
||||
// eslint-disable-next-line
|
||||
@@ -156,22 +150,6 @@ export class MainBiometricsService extends DesktopBiometricsService {
|
||||
return await this.osBiometricsService.hasPersistentKey(userId);
|
||||
}
|
||||
|
||||
async enableWindowsV2Biometrics(): Promise<void> {
|
||||
if (this.platform === "win32" && !this.windowsV2BiometricsEnabled) {
|
||||
this.logService.info("[BiometricsMain] Loading native biometrics module v2 for windows");
|
||||
this.osBiometricsService = new WindowsBiometricsSystem(
|
||||
this.i18nService,
|
||||
this.windowMain,
|
||||
this.logService,
|
||||
);
|
||||
this.windowsV2BiometricsEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
async isWindowsV2BiometricsEnabled(): Promise<boolean> {
|
||||
return this.windowsV2BiometricsEnabled;
|
||||
}
|
||||
|
||||
async enableLinuxV2Biometrics(): Promise<void> {
|
||||
if (this.platform === "linux" && !this.linuxV2BiometricsEnabled) {
|
||||
this.logService.info("[BiometricsMain] Loading native biometrics module v2 for linux");
|
||||
|
||||
@@ -1,378 +0,0 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import { BrowserWindow } from "electron";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { biometrics, passwords } from "@bitwarden/desktop-napi";
|
||||
import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management";
|
||||
|
||||
import { WindowMain } from "../../main/window.main";
|
||||
|
||||
import OsBiometricsServiceWindows from "./os-biometrics-windows.service";
|
||||
|
||||
import OsDerivedKey = biometrics.OsDerivedKey;
|
||||
|
||||
jest.mock("@bitwarden/desktop-napi", () => {
|
||||
return {
|
||||
biometrics: {
|
||||
available: jest.fn().mockResolvedValue(true),
|
||||
getBiometricSecret: jest.fn().mockResolvedValue(""),
|
||||
setBiometricSecret: jest.fn().mockResolvedValue(""),
|
||||
deleteBiometricSecret: jest.fn(),
|
||||
deriveKeyMaterial: jest.fn().mockResolvedValue({
|
||||
keyB64: "",
|
||||
ivB64: "",
|
||||
}),
|
||||
prompt: jest.fn().mockResolvedValue(true),
|
||||
},
|
||||
passwords: {
|
||||
getPassword: jest.fn().mockResolvedValue(null),
|
||||
deletePassword: jest.fn().mockImplementation(() => {}),
|
||||
isAvailable: jest.fn(),
|
||||
PASSWORD_NOT_FOUND: "Password not found",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("OsBiometricsServiceWindows", function () {
|
||||
const i18nService = mock<I18nService>();
|
||||
const windowMain = mock<WindowMain>();
|
||||
const browserWindow = mock<BrowserWindow>();
|
||||
const encryptionService: EncryptService = mock<EncryptService>();
|
||||
const cryptoFunctionService: CryptoFunctionService = mock<CryptoFunctionService>();
|
||||
const biometricStateService: BiometricStateService = mock<BiometricStateService>();
|
||||
const logService = mock<LogService>();
|
||||
|
||||
let service: OsBiometricsServiceWindows;
|
||||
|
||||
const key = new SymmetricCryptoKey(new Uint8Array(64));
|
||||
const userId = "test-user-id" as UserId;
|
||||
const serviceKey = "Bitwarden_biometric";
|
||||
const storageKey = `${userId}_user_biometric`;
|
||||
|
||||
beforeEach(() => {
|
||||
windowMain.win = browserWindow;
|
||||
|
||||
service = new OsBiometricsServiceWindows(
|
||||
i18nService,
|
||||
windowMain,
|
||||
logService,
|
||||
biometricStateService,
|
||||
encryptionService,
|
||||
cryptoFunctionService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getBiometricsFirstUnlockStatusForUser", () => {
|
||||
const userId = "test-user-id" as UserId;
|
||||
it("should return Available when client key half is set", async () => {
|
||||
(service as any).clientKeyHalves = new Map<string, Uint8Array>();
|
||||
(service as any).clientKeyHalves.set(userId, new Uint8Array([1, 2, 3, 4]));
|
||||
const result = await service.getBiometricsFirstUnlockStatusForUser(userId);
|
||||
expect(result).toBe(BiometricsStatus.Available);
|
||||
});
|
||||
it("should return UnlockNeeded when client key half is not set", async () => {
|
||||
(service as any).clientKeyHalves = new Map<string, Uint8Array>();
|
||||
const result = await service.getBiometricsFirstUnlockStatusForUser(userId);
|
||||
expect(result).toBe(BiometricsStatus.UnlockNeeded);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrCreateBiometricEncryptionClientKeyHalf", () => {
|
||||
it("should return cached key half if already present", async () => {
|
||||
const cachedKeyHalf = new Uint8Array([10, 20, 30]);
|
||||
(service as any).clientKeyHalves.set(userId.toString(), cachedKeyHalf);
|
||||
const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
|
||||
expect(result).toBe(cachedKeyHalf);
|
||||
});
|
||||
|
||||
it("should decrypt and return existing encrypted client key half", async () => {
|
||||
biometricStateService.getEncryptedClientKeyHalf = jest
|
||||
.fn()
|
||||
.mockResolvedValue(new Uint8Array([1, 2, 3]));
|
||||
const decrypted = new Uint8Array([4, 5, 6]);
|
||||
encryptionService.decryptBytes = jest.fn().mockResolvedValue(decrypted);
|
||||
|
||||
const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
|
||||
|
||||
expect(biometricStateService.getEncryptedClientKeyHalf).toHaveBeenCalledWith(userId);
|
||||
expect(encryptionService.decryptBytes).toHaveBeenCalledWith(new Uint8Array([1, 2, 3]), key);
|
||||
expect(result).toEqual(decrypted);
|
||||
expect((service as any).clientKeyHalves.get(userId.toString())).toEqual(decrypted);
|
||||
});
|
||||
|
||||
it("should generate, encrypt, store, and cache a new key half if none exists", async () => {
|
||||
biometricStateService.getEncryptedClientKeyHalf = jest.fn().mockResolvedValue(null);
|
||||
const randomBytes = new Uint8Array([7, 8, 9]);
|
||||
cryptoFunctionService.randomBytes = jest.fn().mockResolvedValue(randomBytes);
|
||||
const encrypted = new Uint8Array([10, 11, 12]);
|
||||
encryptionService.encryptBytes = jest.fn().mockResolvedValue(encrypted);
|
||||
biometricStateService.setEncryptedClientKeyHalf = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
|
||||
|
||||
expect(cryptoFunctionService.randomBytes).toHaveBeenCalledWith(32);
|
||||
expect(encryptionService.encryptBytes).toHaveBeenCalledWith(randomBytes, key);
|
||||
expect(biometricStateService.setEncryptedClientKeyHalf).toHaveBeenCalledWith(
|
||||
encrypted,
|
||||
userId,
|
||||
);
|
||||
expect(result).toEqual(randomBytes);
|
||||
expect((service as any).clientKeyHalves.get(userId.toString())).toEqual(randomBytes);
|
||||
});
|
||||
});
|
||||
|
||||
describe("supportsBiometrics", () => {
|
||||
it("should return true if biometrics are available", async () => {
|
||||
biometrics.available = jest.fn().mockResolvedValue(true);
|
||||
|
||||
const result = await service.supportsBiometrics();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if biometrics are not available", async () => {
|
||||
biometrics.available = jest.fn().mockResolvedValue(false);
|
||||
|
||||
const result = await service.supportsBiometrics();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBiometricKey", () => {
|
||||
beforeEach(() => {
|
||||
biometrics.prompt = jest.fn().mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should return null when unsuccessfully authenticated biometrics", async () => {
|
||||
biometrics.prompt = jest.fn().mockResolvedValue(false);
|
||||
|
||||
const result = await service.getBiometricKey(userId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it.each([null, undefined, ""])(
|
||||
"should throw error when no biometric key is found '%s'",
|
||||
async (password) => {
|
||||
passwords.getPassword = jest.fn().mockResolvedValue(password);
|
||||
|
||||
await expect(service.getBiometricKey(userId)).rejects.toThrow(
|
||||
"Biometric key not found for user",
|
||||
);
|
||||
|
||||
expect(passwords.getPassword).toHaveBeenCalledWith(serviceKey, storageKey);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([[false], [true]])(
|
||||
"should return the biometricKey and setBiometricSecret called if password is not encrypted and cached clientKeyHalves is %s",
|
||||
async (haveClientKeyHalves) => {
|
||||
const clientKeyHalveBytes = new Uint8Array([1, 2, 3]);
|
||||
if (haveClientKeyHalves) {
|
||||
service["clientKeyHalves"].set(userId, clientKeyHalveBytes);
|
||||
}
|
||||
const biometricKey = key.toBase64();
|
||||
passwords.getPassword = jest.fn().mockResolvedValue(biometricKey);
|
||||
biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue({
|
||||
keyB64: "testKeyB64",
|
||||
ivB64: "testIvB64",
|
||||
} satisfies OsDerivedKey);
|
||||
|
||||
const result = await service.getBiometricKey(userId);
|
||||
|
||||
expect(result.toBase64()).toBe(biometricKey);
|
||||
expect(passwords.getPassword).toHaveBeenCalledWith(serviceKey, storageKey);
|
||||
expect(biometrics.setBiometricSecret).toHaveBeenCalledWith(
|
||||
serviceKey,
|
||||
storageKey,
|
||||
biometricKey,
|
||||
{
|
||||
osKeyPartB64: "testKeyB64",
|
||||
clientKeyPartB64: haveClientKeyHalves
|
||||
? Utils.fromBufferToB64(clientKeyHalveBytes)
|
||||
: undefined,
|
||||
},
|
||||
"testIvB64",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([[false], [true]])(
|
||||
"should return the biometricKey if password is encrypted and cached clientKeyHalves is %s",
|
||||
async (haveClientKeyHalves) => {
|
||||
const clientKeyHalveBytes = new Uint8Array([1, 2, 3]);
|
||||
if (haveClientKeyHalves) {
|
||||
service["clientKeyHalves"].set(userId, clientKeyHalveBytes);
|
||||
}
|
||||
const biometricKey = key.toBase64();
|
||||
const biometricKeyEncrypted = "2.testId|data|mac";
|
||||
passwords.getPassword = jest.fn().mockResolvedValue(biometricKeyEncrypted);
|
||||
biometrics.getBiometricSecret = jest.fn().mockResolvedValue(biometricKey);
|
||||
biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue({
|
||||
keyB64: "testKeyB64",
|
||||
ivB64: "testIvB64",
|
||||
} satisfies OsDerivedKey);
|
||||
|
||||
const result = await service.getBiometricKey(userId);
|
||||
|
||||
expect(result.toBase64()).toBe(biometricKey);
|
||||
expect(passwords.getPassword).toHaveBeenCalledWith(serviceKey, storageKey);
|
||||
expect(biometrics.setBiometricSecret).not.toHaveBeenCalled();
|
||||
expect(biometrics.getBiometricSecret).toHaveBeenCalledWith(serviceKey, storageKey, {
|
||||
osKeyPartB64: "testKeyB64",
|
||||
clientKeyPartB64: haveClientKeyHalves
|
||||
? Utils.fromBufferToB64(clientKeyHalveBytes)
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("deleteBiometricKey", () => {
|
||||
const serviceName = "Bitwarden_biometric";
|
||||
const keyName = "test-user-id_user_biometric";
|
||||
|
||||
it("should delete biometric key successfully", async () => {
|
||||
await service.deleteBiometricKey(userId);
|
||||
|
||||
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
|
||||
});
|
||||
|
||||
it.each([[false], [true]])("should not throw error if key found: %s", async (keyFound) => {
|
||||
if (!keyFound) {
|
||||
passwords.deletePassword = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error(passwords.PASSWORD_NOT_FOUND));
|
||||
}
|
||||
|
||||
await service.deleteBiometricKey(userId);
|
||||
|
||||
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
|
||||
if (!keyFound) {
|
||||
expect(logService.debug).toHaveBeenCalledWith(
|
||||
"[OsBiometricService] Biometric key %s not found for service %s.",
|
||||
keyName,
|
||||
serviceName,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should throw error when deletePassword for key throws unexpected errors", async () => {
|
||||
const error = new Error("Unexpected error");
|
||||
passwords.deletePassword = jest.fn().mockRejectedValue(error);
|
||||
|
||||
await expect(service.deleteBiometricKey(userId)).rejects.toThrow(error);
|
||||
|
||||
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
|
||||
});
|
||||
});
|
||||
|
||||
describe("authenticateBiometric", () => {
|
||||
const hwnd = randomBytes(32).buffer;
|
||||
const consentMessage = "Test Windows Hello Consent Message";
|
||||
|
||||
beforeEach(() => {
|
||||
windowMain.win.getNativeWindowHandle = jest.fn().mockReturnValue(hwnd);
|
||||
i18nService.t.mockReturnValue(consentMessage);
|
||||
});
|
||||
|
||||
it("should return true when biometric authentication is successful", async () => {
|
||||
const result = await service.authenticateBiometric();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(biometrics.prompt).toHaveBeenCalledWith(hwnd, consentMessage);
|
||||
});
|
||||
|
||||
it("should return false when biometric authentication fails", async () => {
|
||||
biometrics.prompt = jest.fn().mockResolvedValue(false);
|
||||
|
||||
const result = await service.authenticateBiometric();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(biometrics.prompt).toHaveBeenCalledWith(hwnd, consentMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStorageDetails", () => {
|
||||
it.each([
|
||||
["testClientKeyHalfB64", "testIvB64"],
|
||||
[undefined, "testIvB64"],
|
||||
["testClientKeyHalfB64", null],
|
||||
[undefined, null],
|
||||
])(
|
||||
"should derive key material and ivB64 and return it when os key half not saved yet",
|
||||
async (clientKeyHalfB64, ivB64) => {
|
||||
service["setIv"](ivB64);
|
||||
|
||||
const derivedKeyMaterial = {
|
||||
keyB64: "derivedKeyB64",
|
||||
ivB64: "derivedIvB64",
|
||||
};
|
||||
biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue(derivedKeyMaterial);
|
||||
|
||||
const result = await service["getStorageDetails"]({ clientKeyHalfB64 });
|
||||
|
||||
expect(result).toEqual({
|
||||
key_material: {
|
||||
osKeyPartB64: derivedKeyMaterial.keyB64,
|
||||
clientKeyPartB64: clientKeyHalfB64,
|
||||
},
|
||||
ivB64: derivedKeyMaterial.ivB64,
|
||||
});
|
||||
expect(biometrics.deriveKeyMaterial).toHaveBeenCalledWith(ivB64);
|
||||
expect(service["_osKeyHalf"]).toEqual(derivedKeyMaterial.keyB64);
|
||||
expect(service["_iv"]).toEqual(derivedKeyMaterial.ivB64);
|
||||
},
|
||||
);
|
||||
|
||||
it("should throw an error when deriving key material and returned iv is null", async () => {
|
||||
service["setIv"]("testIvB64");
|
||||
|
||||
const derivedKeyMaterial = {
|
||||
keyB64: "derivedKeyB64",
|
||||
ivB64: null as string | undefined | null,
|
||||
};
|
||||
biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue(derivedKeyMaterial);
|
||||
|
||||
await expect(
|
||||
service["getStorageDetails"]({ clientKeyHalfB64: "testClientKeyHalfB64" }),
|
||||
).rejects.toThrow("Initialization Vector is null");
|
||||
|
||||
expect(biometrics.deriveKeyMaterial).toHaveBeenCalledWith("testIvB64");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setIv", () => {
|
||||
it("should set the iv and reset the osKeyHalf", () => {
|
||||
const iv = "testIv";
|
||||
service["_osKeyHalf"] = "testOsKeyHalf";
|
||||
|
||||
service["setIv"](iv);
|
||||
|
||||
expect(service["_iv"]).toBe(iv);
|
||||
expect(service["_osKeyHalf"]).toBeNull();
|
||||
});
|
||||
|
||||
it("should set the iv to null when iv is undefined and reset the osKeyHalf", () => {
|
||||
service["_osKeyHalf"] = "testOsKeyHalf";
|
||||
|
||||
service["setIv"](undefined);
|
||||
|
||||
expect(service["_iv"]).toBeNull();
|
||||
expect(service["_osKeyHalf"]).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,214 +0,0 @@
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { biometrics, passwords } from "@bitwarden/desktop-napi";
|
||||
import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management";
|
||||
|
||||
import { WindowMain } from "../../main/window.main";
|
||||
|
||||
import { OsBiometricService } from "./os-biometrics.service";
|
||||
|
||||
const SERVICE = "Bitwarden_biometric";
|
||||
|
||||
function getLookupKeyForUser(userId: UserId): string {
|
||||
return `${userId}_user_biometric`;
|
||||
}
|
||||
|
||||
export default class OsBiometricsServiceWindows implements OsBiometricService {
|
||||
// Use set helper method instead of direct access
|
||||
private _iv: string | null = null;
|
||||
// Use getKeyMaterial helper instead of direct access
|
||||
private _osKeyHalf: string | null = null;
|
||||
private clientKeyHalves = new Map<UserId, Uint8Array>();
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private windowMain: WindowMain,
|
||||
private logService: LogService,
|
||||
private biometricStateService: BiometricStateService,
|
||||
private encryptService: EncryptService,
|
||||
private cryptoFunctionService: CryptoFunctionService,
|
||||
) {}
|
||||
|
||||
async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise<void> {}
|
||||
|
||||
async hasPersistentKey(userId: UserId): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async supportsBiometrics(): Promise<boolean> {
|
||||
return await biometrics.available();
|
||||
}
|
||||
|
||||
async getBiometricKey(userId: UserId): Promise<SymmetricCryptoKey | null> {
|
||||
const success = await this.authenticateBiometric();
|
||||
if (!success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = await passwords.getPassword(SERVICE, getLookupKeyForUser(userId));
|
||||
if (value == null || value == "") {
|
||||
throw new Error("Biometric key not found for user");
|
||||
}
|
||||
|
||||
let clientKeyHalfB64: string | null = null;
|
||||
if (this.clientKeyHalves.has(userId)) {
|
||||
clientKeyHalfB64 = Utils.fromBufferToB64(this.clientKeyHalves.get(userId)!);
|
||||
}
|
||||
|
||||
if (!EncString.isSerializedEncString(value)) {
|
||||
// Update to format encrypted with client key half
|
||||
const storageDetails = await this.getStorageDetails({
|
||||
clientKeyHalfB64: clientKeyHalfB64 ?? undefined,
|
||||
});
|
||||
|
||||
await biometrics.setBiometricSecret(
|
||||
SERVICE,
|
||||
getLookupKeyForUser(userId),
|
||||
value,
|
||||
storageDetails.key_material,
|
||||
storageDetails.ivB64,
|
||||
);
|
||||
return SymmetricCryptoKey.fromString(value);
|
||||
} else {
|
||||
const encValue = new EncString(value);
|
||||
this.setIv(encValue.iv);
|
||||
const storageDetails = await this.getStorageDetails({
|
||||
clientKeyHalfB64: clientKeyHalfB64 ?? undefined,
|
||||
});
|
||||
return SymmetricCryptoKey.fromString(
|
||||
await biometrics.getBiometricSecret(
|
||||
SERVICE,
|
||||
getLookupKeyForUser(userId),
|
||||
storageDetails.key_material,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
|
||||
const clientKeyHalf = await this.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
|
||||
|
||||
const storageDetails = await this.getStorageDetails({
|
||||
clientKeyHalfB64: Utils.fromBufferToB64(clientKeyHalf),
|
||||
});
|
||||
await biometrics.setBiometricSecret(
|
||||
SERVICE,
|
||||
getLookupKeyForUser(userId),
|
||||
key.toBase64(),
|
||||
storageDetails.key_material,
|
||||
storageDetails.ivB64,
|
||||
);
|
||||
}
|
||||
|
||||
async deleteBiometricKey(userId: UserId): Promise<void> {
|
||||
try {
|
||||
await passwords.deletePassword(SERVICE, getLookupKeyForUser(userId));
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === passwords.PASSWORD_NOT_FOUND) {
|
||||
this.logService.debug(
|
||||
"[OsBiometricService] Biometric key %s not found for service %s.",
|
||||
getLookupKeyForUser(userId),
|
||||
SERVICE,
|
||||
);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts Windows Hello
|
||||
*/
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
const hwnd = this.windowMain.win.getNativeWindowHandle();
|
||||
return await biometrics.prompt(hwnd, this.i18nService.t("windowsHelloConsentMessage"));
|
||||
}
|
||||
|
||||
private async getStorageDetails({
|
||||
clientKeyHalfB64,
|
||||
}: {
|
||||
clientKeyHalfB64: string | undefined;
|
||||
}): Promise<{ key_material: biometrics.KeyMaterial; ivB64: string }> {
|
||||
if (this._osKeyHalf == null) {
|
||||
const keyMaterial = await biometrics.deriveKeyMaterial(this._iv);
|
||||
this._osKeyHalf = keyMaterial.keyB64;
|
||||
this._iv = keyMaterial.ivB64;
|
||||
}
|
||||
|
||||
if (this._iv == null) {
|
||||
throw new Error("Initialization Vector is null");
|
||||
}
|
||||
|
||||
const result = {
|
||||
key_material: {
|
||||
osKeyPartB64: this._osKeyHalf,
|
||||
clientKeyPartB64: clientKeyHalfB64,
|
||||
},
|
||||
ivB64: this._iv,
|
||||
};
|
||||
|
||||
// napi-rs fails to convert null values
|
||||
if (result.key_material.clientKeyPartB64 == null) {
|
||||
delete result.key_material.clientKeyPartB64;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Nulls out key material in order to force a re-derive. This should only be used in getBiometricKey
|
||||
// when we want to force a re-derive of the key material.
|
||||
private setIv(iv?: string) {
|
||||
this._iv = iv ?? null;
|
||||
this._osKeyHalf = null;
|
||||
}
|
||||
|
||||
async needsSetup() {
|
||||
return false;
|
||||
}
|
||||
|
||||
async canAutoSetup(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async runSetup(): Promise<void> {}
|
||||
|
||||
async getOrCreateBiometricEncryptionClientKeyHalf(
|
||||
userId: UserId,
|
||||
key: SymmetricCryptoKey,
|
||||
): Promise<Uint8Array> {
|
||||
if (this.clientKeyHalves.has(userId)) {
|
||||
return this.clientKeyHalves.get(userId)!;
|
||||
}
|
||||
|
||||
// Retrieve existing key half if it exists
|
||||
let clientKeyHalf: Uint8Array | null = null;
|
||||
const encryptedClientKeyHalf =
|
||||
await this.biometricStateService.getEncryptedClientKeyHalf(userId);
|
||||
if (encryptedClientKeyHalf != null) {
|
||||
clientKeyHalf = await this.encryptService.decryptBytes(encryptedClientKeyHalf, key);
|
||||
}
|
||||
if (clientKeyHalf == null) {
|
||||
// Set a key half if it doesn't exist
|
||||
clientKeyHalf = await this.cryptoFunctionService.randomBytes(32);
|
||||
const encKey = await this.encryptService.encryptBytes(clientKeyHalf, key);
|
||||
await this.biometricStateService.setEncryptedClientKeyHalf(encKey, userId);
|
||||
}
|
||||
|
||||
this.clientKeyHalves.set(userId, clientKeyHalf);
|
||||
|
||||
return clientKeyHalf;
|
||||
}
|
||||
|
||||
async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise<BiometricsStatus> {
|
||||
if (this.clientKeyHalves.has(userId)) {
|
||||
return BiometricsStatus.Available;
|
||||
} else {
|
||||
return BiometricsStatus.UnlockNeeded;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,14 +77,6 @@ export class RendererBiometricsService extends DesktopBiometricsService {
|
||||
return await ipc.keyManagement.biometric.hasPersistentKey(userId);
|
||||
}
|
||||
|
||||
async enableWindowsV2Biometrics(): Promise<void> {
|
||||
return await ipc.keyManagement.biometric.enableWindowsV2Biometrics();
|
||||
}
|
||||
|
||||
async isWindowsV2BiometricsEnabled(): Promise<boolean> {
|
||||
return await ipc.keyManagement.biometric.isWindowsV2BiometricsEnabled();
|
||||
}
|
||||
|
||||
async enableLinuxV2Biometrics(): Promise<void> {
|
||||
return await ipc.keyManagement.biometric.enableLinuxV2Biometrics();
|
||||
}
|
||||
|
||||
@@ -61,14 +61,6 @@ const biometric = {
|
||||
action: BiometricAction.HasPersistentKey,
|
||||
userId: userId,
|
||||
} satisfies BiometricMessage),
|
||||
enableWindowsV2Biometrics: (): Promise<void> =>
|
||||
ipcRenderer.invoke("biometric", {
|
||||
action: BiometricAction.EnableWindowsV2,
|
||||
} satisfies BiometricMessage),
|
||||
isWindowsV2BiometricsEnabled: (): Promise<boolean> =>
|
||||
ipcRenderer.invoke("biometric", {
|
||||
action: BiometricAction.IsWindowsV2Enabled,
|
||||
} satisfies BiometricMessage),
|
||||
enableLinuxV2Biometrics: (): Promise<void> =>
|
||||
ipcRenderer.invoke("biometric", {
|
||||
action: BiometricAction.EnableLinuxV2,
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
}
|
||||
},
|
||||
"noEditPermissions": {
|
||||
"message": "Du bist nicht berechtigt, diesen Eintrag zu bearbeiten"
|
||||
"message": "Keine Berechtigung zum Bearbeiten dieses Eintrags"
|
||||
},
|
||||
"welcomeBack": {
|
||||
"message": "Willkommen zurück"
|
||||
@@ -2562,7 +2562,7 @@
|
||||
}
|
||||
},
|
||||
"vaultCustomTimeoutMinimum": {
|
||||
"message": "Das minimal benutzerdefinierte Timeout beträgt 1 Minute."
|
||||
"message": "Minimale benutzerdefinierte Timeout-Zeit beträgt 1 Minute."
|
||||
},
|
||||
"inviteAccepted": {
|
||||
"message": "Einladung angenommen"
|
||||
@@ -4165,7 +4165,7 @@
|
||||
"description": "Verb"
|
||||
},
|
||||
"unArchive": {
|
||||
"message": "Nicht mehr archivieren"
|
||||
"message": "Wiederherstellen"
|
||||
},
|
||||
"itemsInArchive": {
|
||||
"message": "Einträge im Archiv"
|
||||
@@ -4177,10 +4177,10 @@
|
||||
"message": "Archivierte Einträge werden hier angezeigt und von allgemeinen Suchergebnissen sowie Auto-Ausfüllen-Vorschlägen ausgeschlossen."
|
||||
},
|
||||
"itemWasSentToArchive": {
|
||||
"message": "Eintrag wurde ins Archiv verschoben"
|
||||
"message": "Eintrag wurde archiviert"
|
||||
},
|
||||
"itemWasUnarchived": {
|
||||
"message": "Eintrag wird nicht mehr archiviert"
|
||||
"message": "Eintrag wurde wiederhergestellt"
|
||||
},
|
||||
"archiveItem": {
|
||||
"message": "Eintrag archivieren"
|
||||
@@ -4201,22 +4201,22 @@
|
||||
"message": "Integrierter Authenticator"
|
||||
},
|
||||
"secureFileStorage": {
|
||||
"message": "Sicherer Dateispeicher"
|
||||
"message": "Sichere Dateispeicherung"
|
||||
},
|
||||
"emergencyAccess": {
|
||||
"message": "Notfallzugriff"
|
||||
},
|
||||
"breachMonitoring": {
|
||||
"message": "Datendiebstahl-Überwachung"
|
||||
"message": "Datenleck-Überwachung"
|
||||
},
|
||||
"andMoreFeatures": {
|
||||
"message": "Und mehr!"
|
||||
"message": "Und vieles mehr!"
|
||||
},
|
||||
"planDescPremium": {
|
||||
"message": "Umfassende Online-Sicherheit"
|
||||
"message": "Kompletter Online-Sicherheitsplan"
|
||||
},
|
||||
"upgradeToPremium": {
|
||||
"message": "Upgrade auf Premium"
|
||||
"message": "Auf Premium upgraden"
|
||||
},
|
||||
"sessionTimeoutSettingsAction": {
|
||||
"message": "Timeout-Aktion"
|
||||
|
||||
@@ -2228,6 +2228,10 @@
|
||||
"contactInfo": {
|
||||
"message": "Contact information"
|
||||
},
|
||||
"send": {
|
||||
"message": "Send",
|
||||
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
|
||||
},
|
||||
"allSends": {
|
||||
"message": "All Sends",
|
||||
"description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
|
||||
@@ -2991,7 +2995,8 @@
|
||||
"message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected."
|
||||
},
|
||||
"vault": {
|
||||
"message": "Vault"
|
||||
"message": "Vault",
|
||||
"description": "'Vault' is a noun and refers to the Bitwarden Vault feature."
|
||||
},
|
||||
"loginWithMasterPassword": {
|
||||
"message": "Log in with master password"
|
||||
|
||||
@@ -287,7 +287,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.'"
|
||||
},
|
||||
"january": {
|
||||
@@ -2088,16 +2088,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",
|
||||
|
||||
@@ -1387,7 +1387,7 @@
|
||||
"message": "语言"
|
||||
},
|
||||
"languageDesc": {
|
||||
"message": "更改应用程序所使用的语言。重启后生效。"
|
||||
"message": "更改应用程序所使用的语言。需要重启。"
|
||||
},
|
||||
"theme": {
|
||||
"message": "主题"
|
||||
@@ -3980,7 +3980,7 @@
|
||||
"message": "关于此设置"
|
||||
},
|
||||
"permitCipherDetailsDescription": {
|
||||
"message": "Bitwarden 将使用已保存的登录 URI 来识别应使用哪个图标或更改密码的 URL 来改善您的体验。当您使用此服务时,不会收集或保存任何信息。"
|
||||
"message": "Bitwarden 将使用已保存的登录 URI 来确定应使用的图标或更改密码的 URL,以提升您的使用体验。使用此服务时不会收集或保存任何信息。"
|
||||
},
|
||||
"assignToCollections": {
|
||||
"message": "分配到集合"
|
||||
|
||||
4
apps/desktop/src/package-lock.json
generated
4
apps/desktop/src/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@bitwarden/desktop",
|
||||
"version": "2025.11.3",
|
||||
"version": "2025.12.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@bitwarden/desktop",
|
||||
"version": "2025.11.3",
|
||||
"version": "2025.12.0",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"@bitwarden/desktop-napi": "file:../desktop_native/napi"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@bitwarden/desktop",
|
||||
"productName": "Bitwarden",
|
||||
"description": "A secure and free password manager for all of your devices.",
|
||||
"version": "2025.11.3",
|
||||
"version": "2025.12.0",
|
||||
"author": "Bitwarden Inc. <hello@bitwarden.com> (https://bitwarden.com)",
|
||||
"homepage": "https://bitwarden.com",
|
||||
"license": "GPL-3.0",
|
||||
|
||||
@@ -503,19 +503,4 @@ describe("BiometricMessageHandlerService", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("init", () => {
|
||||
it("enables Windows v2 biometrics when feature flag enabled", async () => {
|
||||
configService.getFeatureFlag.mockReturnValue(true);
|
||||
|
||||
await service.init();
|
||||
expect(biometricsService.enableWindowsV2Biometrics).toHaveBeenCalled();
|
||||
});
|
||||
it("does not enable Windows v2 biometrics when feature flag disabled", async () => {
|
||||
configService.getFeatureFlag.mockReturnValue(false);
|
||||
|
||||
await service.init();
|
||||
expect(biometricsService.enableWindowsV2Biometrics).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,13 +119,6 @@ export class BiometricMessageHandlerService {
|
||||
"[BiometricMessageHandlerService] Initializing biometric message handler",
|
||||
);
|
||||
|
||||
const windowsV2Enabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.WindowsBiometricsV2,
|
||||
);
|
||||
if (windowsV2Enabled) {
|
||||
await this.biometricsService.enableWindowsV2Biometrics();
|
||||
}
|
||||
|
||||
const linuxV2Enabled = await this.configService.getFeatureFlag(FeatureFlag.LinuxBiometricsV2);
|
||||
if (linuxV2Enabled) {
|
||||
await this.biometricsService.enableLinuxV2Biometrics();
|
||||
|
||||
@@ -17,9 +17,6 @@ export enum BiometricAction {
|
||||
EnrollPersistent = "enrollPersistent",
|
||||
HasPersistentKey = "hasPersistentKey",
|
||||
|
||||
EnableWindowsV2 = "enableWindowsV2",
|
||||
IsWindowsV2Enabled = "isWindowsV2Enabled",
|
||||
|
||||
EnableLinuxV2 = "enableLinuxV2",
|
||||
IsLinuxV2Enabled = "isLinuxV2Enabled",
|
||||
}
|
||||
|
||||
22
apps/desktop/src/vault/app/vault-v3/vault.component.spec.ts
Normal file
22
apps/desktop/src/vault/app/vault-v3/vault.component.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
|
||||
import { VaultComponent } from "./vault.component";
|
||||
|
||||
describe("VaultComponent", () => {
|
||||
let component: VaultComponent;
|
||||
let fixture: ComponentFixture<VaultComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VaultComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(VaultComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("creates component", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
9
apps/desktop/src/vault/app/vault-v3/vault.component.ts
Normal file
9
apps/desktop/src/vault/app/vault-v3/vault.component.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
|
||||
@Component({
|
||||
selector: "app-vault-v3",
|
||||
imports: [],
|
||||
template: "<p>Vault V3 Component</p>",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class VaultComponent {}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@bitwarden/web-vault",
|
||||
"version": "2025.11.3",
|
||||
"version": "2025.12.1",
|
||||
"scripts": {
|
||||
"build:oss": "webpack",
|
||||
"build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<org-switcher [filter]="orgFilter" [hideNewButton]="hideNewOrgButton$ | async"></org-switcher>
|
||||
<bit-nav-item
|
||||
icon="bwi-dashboard"
|
||||
*ngIf="organization.useAccessIntelligence && organization.canAccessReports"
|
||||
*ngIf="organization.canAccessReports"
|
||||
[text]="'accessIntelligence' | i18n"
|
||||
route="access-intelligence"
|
||||
></bit-nav-item>
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
<app-header></app-header>
|
||||
|
||||
@let organization = organization$ | async;
|
||||
@let policiesEnabledMap = policiesEnabledMap$ | async;
|
||||
@let organizationId = organizationId$ | async;
|
||||
|
||||
<bit-container>
|
||||
@if (loading) {
|
||||
@if (!organization || !policiesEnabledMap || !organizationId) {
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
}
|
||||
@if (!loading) {
|
||||
} @else {
|
||||
<bit-table>
|
||||
<ng-template body>
|
||||
@for (p of policies$ | async; track p.type) {
|
||||
<tr bitRow>
|
||||
<td bitCell ngPreserveWhitespaces>
|
||||
<button type="button" bitLink (click)="edit(p)">{{ p.name | i18n }}</button>
|
||||
@if (policiesEnabledMap.get(p.type)) {
|
||||
<span bitBadge variant="success">{{ "on" | i18n }}</span>
|
||||
}
|
||||
<small class="tw-text-muted tw-block">{{ p.description | i18n }}</small>
|
||||
</td>
|
||||
</tr>
|
||||
@for (p of policies$ | async; track $index) {
|
||||
@if (p.display$(organization, configService) | async) {
|
||||
<tr bitRow>
|
||||
<td bitCell ngPreserveWhitespaces>
|
||||
<button type="button" bitLink (click)="edit(p, organizationId)">
|
||||
{{ p.name | i18n }}
|
||||
</button>
|
||||
@if (policiesEnabledMap.get(p.type)) {
|
||||
<span bitBadge variant="success">{{ "on" | i18n }}</span>
|
||||
}
|
||||
<small class="tw-text-muted tw-block">{{ p.description | i18n }}</small>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
import { NO_ERRORS_SCHEMA } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of, firstValueFrom } from "rxjs";
|
||||
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
|
||||
import { BasePolicyEditDefinition } from "./base-policy-edit.component";
|
||||
import { PoliciesComponent } from "./policies.component";
|
||||
import { SingleOrgPolicy } from "./policy-edit-definitions/single-org.component";
|
||||
import { PolicyEditDialogComponent } from "./policy-edit-dialog.component";
|
||||
import { PolicyListService } from "./policy-list.service";
|
||||
import { POLICY_EDIT_REGISTER } from "./policy-register-token";
|
||||
|
||||
describe("PoliciesComponent", () => {
|
||||
let component: PoliciesComponent;
|
||||
let fixture: ComponentFixture<PoliciesComponent>;
|
||||
|
||||
let mockActivatedRoute: ActivatedRoute;
|
||||
let mockOrganizationService: MockProxy<OrganizationService>;
|
||||
let mockAccountService: FakeAccountService;
|
||||
let mockPolicyApiService: MockProxy<PolicyApiServiceAbstraction>;
|
||||
let mockPolicyListService: MockProxy<PolicyListService>;
|
||||
let mockDialogService: MockProxy<DialogService>;
|
||||
let mockPolicyService: MockProxy<PolicyService>;
|
||||
let mockConfigService: MockProxy<ConfigService>;
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
let mockPlatformUtilsService: MockProxy<PlatformUtilsService>;
|
||||
|
||||
let routeParamsSubject: BehaviorSubject<any>;
|
||||
let queryParamsSubject: BehaviorSubject<any>;
|
||||
|
||||
const mockUserId = newGuid() as UserId;
|
||||
const mockOrgId = newGuid() as OrganizationId;
|
||||
const mockOrg = {
|
||||
id: mockOrgId,
|
||||
name: "Test Organization",
|
||||
enabled: true,
|
||||
} as Organization;
|
||||
|
||||
const mockPolicyResponse = {
|
||||
id: newGuid(),
|
||||
enabled: true,
|
||||
object: "policy",
|
||||
organizationId: mockOrgId,
|
||||
type: PolicyType.SingleOrg,
|
||||
data: null,
|
||||
};
|
||||
|
||||
const mockPolicy = new SingleOrgPolicy();
|
||||
|
||||
beforeEach(async () => {
|
||||
routeParamsSubject = new BehaviorSubject({ organizationId: mockOrgId });
|
||||
queryParamsSubject = new BehaviorSubject({});
|
||||
|
||||
mockActivatedRoute = {
|
||||
params: routeParamsSubject.asObservable(),
|
||||
queryParams: queryParamsSubject.asObservable(),
|
||||
} as any;
|
||||
|
||||
mockOrganizationService = mock<OrganizationService>();
|
||||
mockOrganizationService.organizations$.mockReturnValue(of([mockOrg]));
|
||||
|
||||
mockAccountService = mockAccountServiceWith(mockUserId);
|
||||
|
||||
mockPolicyApiService = mock<PolicyApiServiceAbstraction>();
|
||||
mockPolicyApiService.getPolicies.mockResolvedValue(
|
||||
new ListResponse({ Data: [mockPolicyResponse], ContinuationToken: null }, PolicyResponse),
|
||||
);
|
||||
|
||||
mockPolicyListService = mock<PolicyListService>();
|
||||
mockPolicyListService.getPolicies.mockReturnValue([mockPolicy]);
|
||||
|
||||
mockDialogService = mock<DialogService>();
|
||||
mockDialogService.open.mockReturnValue({ close: jest.fn() } as any);
|
||||
|
||||
mockPolicyService = mock<PolicyService>();
|
||||
mockPolicyService.policies$.mockReturnValue(of([]));
|
||||
|
||||
mockConfigService = mock<ConfigService>();
|
||||
mockI18nService = mock<I18nService>();
|
||||
mockPlatformUtilsService = mock<PlatformUtilsService>();
|
||||
|
||||
jest.spyOn(PolicyEditDialogComponent, "open").mockReturnValue({ close: jest.fn() } as any);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PoliciesComponent],
|
||||
providers: [
|
||||
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
|
||||
{ provide: OrganizationService, useValue: mockOrganizationService },
|
||||
{ provide: AccountService, useValue: mockAccountService },
|
||||
{ provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService },
|
||||
{ provide: PolicyListService, useValue: mockPolicyListService },
|
||||
{ provide: DialogService, useValue: mockDialogService },
|
||||
{ provide: PolicyService, useValue: mockPolicyService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
|
||||
{ provide: POLICY_EDIT_REGISTER, useValue: [] },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
})
|
||||
.overrideComponent(PoliciesComponent, {
|
||||
remove: { imports: [] },
|
||||
add: { template: "<div></div>" },
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PoliciesComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fixture) {
|
||||
fixture.destroy();
|
||||
}
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("organizationId$", () => {
|
||||
it("should extract organizationId from route params", async () => {
|
||||
const orgId = await firstValueFrom(component.organizationId$);
|
||||
expect(orgId).toBe(mockOrgId);
|
||||
});
|
||||
|
||||
it("should emit new organizationId when route params change", (done) => {
|
||||
const newOrgId = newGuid() as OrganizationId;
|
||||
const emittedValues: OrganizationId[] = [];
|
||||
|
||||
const subscription = component.organizationId$.subscribe((orgId) => {
|
||||
emittedValues.push(orgId);
|
||||
|
||||
if (emittedValues.length === 2) {
|
||||
expect(emittedValues[0]).toBe(mockOrgId);
|
||||
expect(emittedValues[1]).toBe(newOrgId);
|
||||
subscription.unsubscribe();
|
||||
done();
|
||||
}
|
||||
});
|
||||
|
||||
routeParamsSubject.next({ organizationId: newOrgId });
|
||||
});
|
||||
});
|
||||
|
||||
describe("organization$", () => {
|
||||
it("should retrieve organization for current user and organizationId", async () => {
|
||||
const org = await firstValueFrom(component.organization$);
|
||||
expect(org).toBe(mockOrg);
|
||||
expect(mockOrganizationService.organizations$).toHaveBeenCalledWith(mockUserId);
|
||||
});
|
||||
|
||||
it("should throw error when organization is not found", async () => {
|
||||
mockOrganizationService.organizations$.mockReturnValue(of([]));
|
||||
|
||||
await expect(firstValueFrom(component.organization$)).rejects.toThrow(
|
||||
"No organization found for provided userId",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policies$", () => {
|
||||
it("should return policies from PolicyListService", async () => {
|
||||
const policies = await firstValueFrom(component.policies$);
|
||||
|
||||
expect(policies).toBeDefined();
|
||||
expect(Array.isArray(policies)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("orgPolicies$", () => {
|
||||
it("should fetch policies from API for current organization", async () => {
|
||||
const mockPolicyResponsesData = [
|
||||
{
|
||||
id: newGuid(),
|
||||
organizationId: mockOrgId,
|
||||
type: PolicyType.TwoFactorAuthentication,
|
||||
enabled: true,
|
||||
data: null,
|
||||
},
|
||||
{
|
||||
id: newGuid(),
|
||||
organizationId: mockOrgId,
|
||||
type: PolicyType.RequireSso,
|
||||
enabled: false,
|
||||
data: null,
|
||||
},
|
||||
];
|
||||
|
||||
const listResponse = new ListResponse(
|
||||
{ Data: mockPolicyResponsesData, ContinuationToken: null },
|
||||
PolicyResponse,
|
||||
);
|
||||
|
||||
mockPolicyApiService.getPolicies.mockResolvedValue(listResponse);
|
||||
|
||||
const policies = await firstValueFrom(component["orgPolicies$"]);
|
||||
expect(policies).toEqual(listResponse.data);
|
||||
expect(mockPolicyApiService.getPolicies).toHaveBeenCalledWith(mockOrgId);
|
||||
});
|
||||
|
||||
it("should return empty array when API returns no data", async () => {
|
||||
mockPolicyApiService.getPolicies.mockResolvedValue(
|
||||
new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse),
|
||||
);
|
||||
|
||||
const policies = await firstValueFrom(component["orgPolicies$"]);
|
||||
expect(policies).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array when API returns null data", async () => {
|
||||
mockPolicyApiService.getPolicies.mockResolvedValue(
|
||||
new ListResponse({ Data: null, ContinuationToken: null }, PolicyResponse),
|
||||
);
|
||||
|
||||
const policies = await firstValueFrom(component["orgPolicies$"]);
|
||||
expect(policies).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policiesEnabledMap$", () => {
|
||||
it("should create a map of policy types to their enabled status", async () => {
|
||||
const mockPolicyResponsesData = [
|
||||
{
|
||||
id: "policy-1",
|
||||
organizationId: mockOrgId,
|
||||
type: PolicyType.TwoFactorAuthentication,
|
||||
enabled: true,
|
||||
data: null,
|
||||
},
|
||||
{
|
||||
id: "policy-2",
|
||||
organizationId: mockOrgId,
|
||||
type: PolicyType.RequireSso,
|
||||
enabled: false,
|
||||
data: null,
|
||||
},
|
||||
{
|
||||
id: "policy-3",
|
||||
organizationId: mockOrgId,
|
||||
type: PolicyType.SingleOrg,
|
||||
enabled: true,
|
||||
data: null,
|
||||
},
|
||||
];
|
||||
|
||||
mockPolicyApiService.getPolicies.mockResolvedValue(
|
||||
new ListResponse(
|
||||
{ Data: mockPolicyResponsesData, ContinuationToken: null },
|
||||
PolicyResponse,
|
||||
),
|
||||
);
|
||||
|
||||
const map = await firstValueFrom(component.policiesEnabledMap$);
|
||||
expect(map.size).toBe(3);
|
||||
expect(map.get(PolicyType.TwoFactorAuthentication)).toBe(true);
|
||||
expect(map.get(PolicyType.RequireSso)).toBe(false);
|
||||
expect(map.get(PolicyType.SingleOrg)).toBe(true);
|
||||
});
|
||||
|
||||
it("should create empty map when no policies exist", async () => {
|
||||
mockPolicyApiService.getPolicies.mockResolvedValue(
|
||||
new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse),
|
||||
);
|
||||
|
||||
const map = await firstValueFrom(component.policiesEnabledMap$);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("constructor subscription", () => {
|
||||
it("should subscribe to policyService.policies$ on initialization", () => {
|
||||
expect(mockPolicyService.policies$).toHaveBeenCalledWith(mockUserId);
|
||||
});
|
||||
|
||||
it("should refresh policies when policyService emits", async () => {
|
||||
const policiesSubject = new BehaviorSubject<any[]>([]);
|
||||
mockPolicyService.policies$.mockReturnValue(policiesSubject.asObservable());
|
||||
|
||||
let callCount = 0;
|
||||
mockPolicyApiService.getPolicies.mockImplementation(() => {
|
||||
callCount++;
|
||||
return of(new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse));
|
||||
});
|
||||
|
||||
const newFixture = TestBed.createComponent(PoliciesComponent);
|
||||
newFixture.detectChanges();
|
||||
|
||||
const initialCallCount = callCount;
|
||||
|
||||
policiesSubject.next([{ type: PolicyType.TwoFactorAuthentication }]);
|
||||
|
||||
expect(callCount).toBeGreaterThan(initialCallCount);
|
||||
|
||||
newFixture.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleLaunchEvent", () => {
|
||||
it("should open policy dialog when policyId is in query params", async () => {
|
||||
const mockPolicyId = newGuid();
|
||||
const mockPolicy: BasePolicyEditDefinition = {
|
||||
name: "Test Policy",
|
||||
description: "Test Description",
|
||||
type: PolicyType.TwoFactorAuthentication,
|
||||
component: {} as any,
|
||||
showDescription: true,
|
||||
display$: () => of(true),
|
||||
};
|
||||
|
||||
const mockPolicyResponseData = {
|
||||
id: mockPolicyId,
|
||||
organizationId: mockOrgId,
|
||||
type: PolicyType.TwoFactorAuthentication,
|
||||
enabled: true,
|
||||
data: null,
|
||||
};
|
||||
|
||||
queryParamsSubject.next({ policyId: mockPolicyId });
|
||||
|
||||
mockPolicyApiService.getPolicies.mockReturnValue(
|
||||
of(
|
||||
new ListResponse(
|
||||
{ Data: [mockPolicyResponseData], ContinuationToken: null },
|
||||
PolicyResponse,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const dialogOpenSpy = jest
|
||||
.spyOn(PolicyEditDialogComponent, "open")
|
||||
.mockReturnValue({ close: jest.fn() } as any);
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PoliciesComponent],
|
||||
providers: [
|
||||
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
|
||||
{ provide: OrganizationService, useValue: mockOrganizationService },
|
||||
{ provide: AccountService, useValue: mockAccountService },
|
||||
{ provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService },
|
||||
{ provide: PolicyListService, useValue: mockPolicyListService },
|
||||
{ provide: DialogService, useValue: mockDialogService },
|
||||
{ provide: PolicyService, useValue: mockPolicyService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
|
||||
{ provide: POLICY_EDIT_REGISTER, useValue: [mockPolicy] },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
})
|
||||
.overrideComponent(PoliciesComponent, {
|
||||
remove: { imports: [] },
|
||||
add: { template: "<div></div>" },
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
const newFixture = TestBed.createComponent(PoliciesComponent);
|
||||
newFixture.detectChanges();
|
||||
|
||||
expect(dialogOpenSpy).toHaveBeenCalled();
|
||||
const callArgs = dialogOpenSpy.mock.calls[0][1];
|
||||
expect(callArgs.data?.policy.type).toBe(mockPolicy.type);
|
||||
expect(callArgs.data?.organizationId).toBe(mockOrgId);
|
||||
|
||||
newFixture.destroy();
|
||||
});
|
||||
|
||||
it("should not open dialog when policyId is not in query params", async () => {
|
||||
const editSpy = jest.spyOn(component, "edit");
|
||||
|
||||
queryParamsSubject.next({});
|
||||
|
||||
expect(editSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not open dialog when policyId does not match any org policy", async () => {
|
||||
const mockPolicy: BasePolicyEditDefinition = {
|
||||
name: "Test Policy",
|
||||
description: "Test Description",
|
||||
type: PolicyType.TwoFactorAuthentication,
|
||||
component: {} as any,
|
||||
showDescription: true,
|
||||
display$: () => of(true),
|
||||
};
|
||||
|
||||
mockPolicyListService.getPolicies.mockReturnValue([mockPolicy]);
|
||||
mockPolicyApiService.getPolicies.mockResolvedValue(
|
||||
new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse),
|
||||
);
|
||||
|
||||
const editSpy = jest.spyOn(component, "edit");
|
||||
|
||||
queryParamsSubject.next({ policyId: "non-existent-policy-id" });
|
||||
|
||||
expect(editSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("edit", () => {
|
||||
it("should call dialogService.open with correct parameters when no custom dialog is specified", () => {
|
||||
const mockPolicy: BasePolicyEditDefinition = {
|
||||
name: "Test Policy",
|
||||
description: "Test Description",
|
||||
type: PolicyType.TwoFactorAuthentication,
|
||||
component: {} as any,
|
||||
showDescription: true,
|
||||
display$: () => of(true),
|
||||
};
|
||||
|
||||
const openSpy = jest.spyOn(PolicyEditDialogComponent, "open");
|
||||
|
||||
component.edit(mockPolicy, mockOrgId);
|
||||
|
||||
expect(openSpy).toHaveBeenCalled();
|
||||
const callArgs = openSpy.mock.calls[0];
|
||||
expect(callArgs[1]).toEqual({
|
||||
data: {
|
||||
policy: mockPolicy,
|
||||
organizationId: mockOrgId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should call custom dialog open method when specified", () => {
|
||||
const mockDialogRef = { close: jest.fn() };
|
||||
const mockCustomDialog = {
|
||||
open: jest.fn().mockReturnValue(mockDialogRef),
|
||||
};
|
||||
|
||||
const mockPolicy: BasePolicyEditDefinition = {
|
||||
name: "Custom Policy",
|
||||
description: "Custom Description",
|
||||
type: PolicyType.RequireSso,
|
||||
component: {} as any,
|
||||
editDialogComponent: mockCustomDialog as any,
|
||||
showDescription: true,
|
||||
display$: () => of(true),
|
||||
};
|
||||
|
||||
component.edit(mockPolicy, mockOrgId);
|
||||
|
||||
expect(mockCustomDialog.open).toHaveBeenCalled();
|
||||
const callArgs = mockCustomDialog.open.mock.calls[0];
|
||||
expect(callArgs[1]).toEqual({
|
||||
data: {
|
||||
policy: mockPolicy,
|
||||
organizationId: mockOrgId,
|
||||
},
|
||||
});
|
||||
expect(PolicyEditDialogComponent.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should pass correct organizationId to dialog", () => {
|
||||
const customOrgId = newGuid() as OrganizationId;
|
||||
const mockPolicy: BasePolicyEditDefinition = {
|
||||
name: "Test Policy",
|
||||
description: "Test Description",
|
||||
type: PolicyType.SingleOrg,
|
||||
component: {} as any,
|
||||
showDescription: true,
|
||||
display$: () => of(true),
|
||||
};
|
||||
|
||||
const openSpy = jest.spyOn(PolicyEditDialogComponent, "open");
|
||||
|
||||
component.edit(mockPolicy, customOrgId);
|
||||
|
||||
expect(openSpy).toHaveBeenCalled();
|
||||
const callArgs = openSpy.mock.calls[0];
|
||||
expect(callArgs[1]).toEqual({
|
||||
data: {
|
||||
policy: mockPolicy,
|
||||
organizationId: customOrgId,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,19 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import {
|
||||
combineLatest,
|
||||
firstValueFrom,
|
||||
Observable,
|
||||
of,
|
||||
switchMap,
|
||||
first,
|
||||
map,
|
||||
withLatestFrom,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
import { combineLatest, Observable, of, switchMap, first, map } from "rxjs";
|
||||
|
||||
import {
|
||||
getOrganizationById,
|
||||
OrganizationService,
|
||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { safeProvider } from "@bitwarden/ui-common";
|
||||
|
||||
@@ -37,8 +25,6 @@ import { PolicyEditDialogComponent } from "./policy-edit-dialog.component";
|
||||
import { PolicyListService } from "./policy-list.service";
|
||||
import { POLICY_EDIT_REGISTER } from "./policy-register-token";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
templateUrl: "policies.component.html",
|
||||
imports: [SharedModule, HeaderModule],
|
||||
@@ -48,14 +34,53 @@ import { POLICY_EDIT_REGISTER } from "./policy-register-token";
|
||||
deps: [POLICY_EDIT_REGISTER],
|
||||
}),
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PoliciesComponent implements OnInit {
|
||||
loading = true;
|
||||
organizationId: string;
|
||||
policies$: Observable<BasePolicyEditDefinition[]>;
|
||||
export class PoliciesComponent {
|
||||
private userId$: Observable<UserId> = this.accountService.activeAccount$.pipe(getUserId);
|
||||
|
||||
private orgPolicies: PolicyResponse[];
|
||||
protected policiesEnabledMap: Map<PolicyType, boolean> = new Map<PolicyType, boolean>();
|
||||
protected organizationId$: Observable<OrganizationId> = this.route.params.pipe(
|
||||
map((params) => params.organizationId),
|
||||
);
|
||||
|
||||
protected organization$: Observable<Organization> = combineLatest([
|
||||
this.userId$,
|
||||
this.organizationId$,
|
||||
]).pipe(
|
||||
switchMap(([userId, orgId]) =>
|
||||
this.organizationService.organizations$(userId).pipe(
|
||||
getById(orgId),
|
||||
map((org) => {
|
||||
if (org == null) {
|
||||
throw new Error("No organization found for provided userId");
|
||||
}
|
||||
return org;
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
protected policies$: Observable<readonly BasePolicyEditDefinition[]> = of(
|
||||
this.policyListService.getPolicies(),
|
||||
);
|
||||
|
||||
private orgPolicies$: Observable<PolicyResponse[]> = this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) => this.policyService.policies$(userId)),
|
||||
switchMap(() => this.organizationId$),
|
||||
switchMap((organizationId) => this.policyApiService.getPolicies(organizationId)),
|
||||
map((response) => (response.data != null && response.data.length > 0 ? response.data : [])),
|
||||
);
|
||||
|
||||
protected policiesEnabledMap$: Observable<Map<PolicyType, boolean>> = this.orgPolicies$.pipe(
|
||||
map((orgPolicies) => {
|
||||
const policiesEnabledMap: Map<PolicyType, boolean> = new Map<PolicyType, boolean>();
|
||||
orgPolicies.forEach((op) => {
|
||||
policiesEnabledMap.set(op.type, op.enabled);
|
||||
});
|
||||
return policiesEnabledMap;
|
||||
}),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
@@ -66,60 +91,28 @@ export class PoliciesComponent implements OnInit {
|
||||
private dialogService: DialogService,
|
||||
private policyService: PolicyService,
|
||||
protected configService: ConfigService,
|
||||
private destroyRef: DestroyRef,
|
||||
) {
|
||||
this.accountService.activeAccount$
|
||||
.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) => this.policyService.policies$(userId)),
|
||||
tap(async () => await this.load()),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe();
|
||||
this.handleLaunchEvent();
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
||||
this.route.parent.parent.params.subscribe(async (params) => {
|
||||
this.organizationId = params.organizationId;
|
||||
const userId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
);
|
||||
|
||||
const organization$ = this.organizationService
|
||||
.organizations$(userId)
|
||||
.pipe(getOrganizationById(this.organizationId));
|
||||
|
||||
this.policies$ = organization$.pipe(
|
||||
withLatestFrom(of(this.policyListService.getPolicies())),
|
||||
switchMap(([organization, policies]) => {
|
||||
return combineLatest(
|
||||
policies.map((policy) =>
|
||||
policy
|
||||
.display$(organization, this.configService)
|
||||
.pipe(map((shouldDisplay) => ({ policy, shouldDisplay }))),
|
||||
),
|
||||
);
|
||||
}),
|
||||
map((results) =>
|
||||
results.filter((result) => result.shouldDisplay).map((result) => result.policy),
|
||||
),
|
||||
);
|
||||
|
||||
await this.load();
|
||||
|
||||
// Handle policies component launch from Event message
|
||||
combineLatest([this.route.queryParams.pipe(first()), this.policies$])
|
||||
/* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */
|
||||
.subscribe(async ([qParams, policies]) => {
|
||||
// Handle policies component launch from Event message
|
||||
private handleLaunchEvent() {
|
||||
combineLatest([
|
||||
this.route.queryParams.pipe(first()),
|
||||
this.policies$,
|
||||
this.organizationId$,
|
||||
this.orgPolicies$,
|
||||
])
|
||||
.pipe(
|
||||
map(([qParams, policies, organizationId, orgPolicies]) => {
|
||||
if (qParams.policyId != null) {
|
||||
const policyIdFromEvents: string = qParams.policyId;
|
||||
for (const orgPolicy of this.orgPolicies) {
|
||||
for (const orgPolicy of orgPolicies) {
|
||||
if (orgPolicy.id === policyIdFromEvents) {
|
||||
for (let i = 0; i < policies.length; i++) {
|
||||
if (policies[i].type === orgPolicy.type) {
|
||||
// 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.edit(policies[i]);
|
||||
this.edit(policies[i], organizationId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -127,27 +120,19 @@ export class PoliciesComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
async load() {
|
||||
const response = await this.policyApiService.getPolicies(this.organizationId);
|
||||
this.orgPolicies = response.data != null && response.data.length > 0 ? response.data : [];
|
||||
this.orgPolicies.forEach((op) => {
|
||||
this.policiesEnabledMap.set(op.type, op.enabled);
|
||||
});
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
async edit(policy: BasePolicyEditDefinition) {
|
||||
edit(policy: BasePolicyEditDefinition, organizationId: OrganizationId) {
|
||||
const dialogComponent: PolicyDialogComponent =
|
||||
policy.editDialogComponent ?? PolicyEditDialogComponent;
|
||||
dialogComponent.open(this.dialogService, {
|
||||
data: {
|
||||
policy: policy,
|
||||
organizationId: this.organizationId,
|
||||
organizationId: organizationId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Component, OnInit, Signal, TemplateRef, viewChild } from "@angular/core";
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
OnInit,
|
||||
Signal,
|
||||
TemplateRef,
|
||||
viewChild,
|
||||
} from "@angular/core";
|
||||
import { BehaviorSubject, map, Observable } from "rxjs";
|
||||
|
||||
import { AutoConfirmSvg } from "@bitwarden/assets/svg";
|
||||
@@ -26,11 +33,11 @@ export class AutoConfirmPolicy extends BasePolicyEditDefinition {
|
||||
}
|
||||
}
|
||||
|
||||
// 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: "auto-confirm-policy-edit",
|
||||
templateUrl: "auto-confirm-policy.component.html",
|
||||
imports: [SharedModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AutoConfirmPolicyEditComponent extends BasePolicyEditComponent implements OnInit {
|
||||
protected readonly autoConfirmSvg = AutoConfirmSvg;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
@@ -18,10 +18,10 @@ export class DesktopAutotypeDefaultSettingPolicy extends BasePolicyEditDefinitio
|
||||
return configService.getFeatureFlag$(FeatureFlag.WindowsDesktopAutotype);
|
||||
}
|
||||
}
|
||||
// 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: "autotype-policy-edit",
|
||||
templateUrl: "autotype-policy.component.html",
|
||||
imports: [SharedModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DesktopAutotypeDefaultSettingPolicyComponent extends BasePolicyEditComponent {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
|
||||
@@ -12,10 +12,10 @@ export class DisableSendPolicy extends BasePolicyEditDefinition {
|
||||
component = DisableSendPolicyComponent;
|
||||
}
|
||||
|
||||
// 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: "disable-send-policy-edit",
|
||||
templateUrl: "disable-send.component.html",
|
||||
imports: [SharedModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DisableSendPolicyComponent extends BasePolicyEditComponent {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, OnInit } from "@angular/core";
|
||||
import { FormBuilder, FormGroup, Validators } from "@angular/forms";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
@@ -26,11 +26,11 @@ export class MasterPasswordPolicy extends BasePolicyEditDefinition {
|
||||
component = MasterPasswordPolicyComponent;
|
||||
}
|
||||
|
||||
// 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: "master-password-policy-edit",
|
||||
templateUrl: "master-password.component.html",
|
||||
imports: [SharedModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class MasterPasswordPolicyComponent extends BasePolicyEditComponent implements OnInit {
|
||||
MinPasswordLength = Utils.minimumPasswordLength;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
import { map, Observable } from "rxjs";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
@@ -22,10 +22,10 @@ export class OrganizationDataOwnershipPolicy extends BasePolicyEditDefinition {
|
||||
}
|
||||
}
|
||||
|
||||
// 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: "organization-data-ownership-policy-edit",
|
||||
templateUrl: "organization-data-ownership.component.html",
|
||||
imports: [SharedModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class OrganizationDataOwnershipPolicyComponent extends BasePolicyEditComponent {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { UntypedFormBuilder, Validators } from "@angular/forms";
|
||||
import { BehaviorSubject, map } from "rxjs";
|
||||
@@ -19,11 +19,11 @@ export class PasswordGeneratorPolicy extends BasePolicyEditDefinition {
|
||||
component = PasswordGeneratorPolicyComponent;
|
||||
}
|
||||
|
||||
// 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: "password-generator-policy-edit",
|
||||
templateUrl: "password-generator.component.html",
|
||||
imports: [SharedModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PasswordGeneratorPolicyComponent extends BasePolicyEditComponent {
|
||||
// these properties forward the application default settings to the UI
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
|
||||
@@ -12,10 +12,10 @@ export class RemoveUnlockWithPinPolicy extends BasePolicyEditDefinition {
|
||||
component = RemoveUnlockWithPinPolicyComponent;
|
||||
}
|
||||
|
||||
// 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: "remove-unlock-with-pin-policy-edit",
|
||||
templateUrl: "remove-unlock-with-pin.component.html",
|
||||
imports: [SharedModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class RemoveUnlockWithPinPolicyComponent extends BasePolicyEditComponent {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
@@ -19,10 +19,10 @@ export class RequireSsoPolicy extends BasePolicyEditDefinition {
|
||||
}
|
||||
}
|
||||
|
||||
// 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: "require-sso-policy-edit",
|
||||
templateUrl: "require-sso.component.html",
|
||||
imports: [SharedModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class RequireSsoPolicyComponent extends BasePolicyEditComponent {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, OnInit } from "@angular/core";
|
||||
import { FormBuilder } from "@angular/forms";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
@@ -26,11 +26,11 @@ export class ResetPasswordPolicy extends BasePolicyEditDefinition {
|
||||
}
|
||||
}
|
||||
|
||||
// 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: "reset-password-policy-edit",
|
||||
templateUrl: "reset-password.component.html",
|
||||
imports: [SharedModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ResetPasswordPolicyComponent extends BasePolicyEditComponent implements OnInit {
|
||||
data = this.formBuilder.group({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
|
||||
@@ -12,11 +12,11 @@ export class RestrictedItemTypesPolicy extends BasePolicyEditDefinition {
|
||||
component = RestrictedItemTypesPolicyComponent;
|
||||
}
|
||||
|
||||
// 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: "restricted-item-types-policy-edit",
|
||||
templateUrl: "restricted-item-types.component.html",
|
||||
imports: [SharedModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class RestrictedItemTypesPolicyComponent extends BasePolicyEditComponent {
|
||||
constructor() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
import { UntypedFormBuilder } from "@angular/forms";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
@@ -13,11 +13,11 @@ export class SendOptionsPolicy extends BasePolicyEditDefinition {
|
||||
component = SendOptionsPolicyComponent;
|
||||
}
|
||||
|
||||
// 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: "send-options-policy-edit",
|
||||
templateUrl: "send-options.component.html",
|
||||
imports: [SharedModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SendOptionsPolicyComponent extends BasePolicyEditComponent {
|
||||
data = this.formBuilder.group({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, OnInit } from "@angular/core";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
|
||||
@@ -12,11 +12,11 @@ export class SingleOrgPolicy extends BasePolicyEditDefinition {
|
||||
component = SingleOrgPolicyComponent;
|
||||
}
|
||||
|
||||
// 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: "single-org-policy-edit",
|
||||
templateUrl: "single-org.component.html",
|
||||
imports: [SharedModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SingleOrgPolicyComponent extends BasePolicyEditComponent implements OnInit {
|
||||
async ngOnInit() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
|
||||
@@ -12,10 +12,10 @@ export class TwoFactorAuthenticationPolicy extends BasePolicyEditDefinition {
|
||||
component = TwoFactorAuthenticationPolicyComponent;
|
||||
}
|
||||
|
||||
// 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: "two-factor-authentication-policy-edit",
|
||||
templateUrl: "two-factor-authentication.component.html",
|
||||
imports: [SharedModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TwoFactorAuthenticationPolicyComponent extends BasePolicyEditComponent {}
|
||||
|
||||
@@ -19,6 +19,7 @@ export class UriMatchDefaultPolicy extends BasePolicyEditDefinition {
|
||||
component = UriMatchDefaultPolicyComponent;
|
||||
}
|
||||
@Component({
|
||||
selector: "uri-match-default-policy-edit",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: "uri-match-default.component.html",
|
||||
imports: [SharedModule],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user