diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.html
index 55674aa83e5..898d93da32c 100644
--- a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.html
+++ b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.html
@@ -3,6 +3,7 @@
[placeholder]="'search' | i18n"
[(ngModel)]="searchText"
(ngModelChange)="onSearchTextChanged()"
+ appAutofocus
>
diff --git a/apps/browser/src/vault/popup/components/vault/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault/vault-v2.component.html
index 62f0d240d1d..8c651e882c5 100644
--- a/apps/browser/src/vault/popup/components/vault/vault-v2.component.html
+++ b/apps/browser/src/vault/popup/components/vault/vault-v2.component.html
@@ -23,7 +23,11 @@
-
+
diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock
index a1ff96ec65d..53d151397f6 100644
--- a/apps/desktop/desktop_native/Cargo.lock
+++ b/apps/desktop/desktop_native/Cargo.lock
@@ -2431,18 +2431,18 @@ dependencies = [
[[package]]
name = "thiserror"
-version = "1.0.68"
+version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "02dd99dc800bbb97186339685293e1cc5d9df1f8fae2d0aecd9ff1c77efea892"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
-version = "1.0.68"
+version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a7c61ec9a6f64d2793d8a45faba21efbe3ced62a886d44c36a009b2b519b4c7e"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
diff --git a/apps/desktop/desktop_native/core/Cargo.toml b/apps/desktop/desktop_native/core/Cargo.toml
index 7ed708fc577..4f6fdb47fdf 100644
--- a/apps/desktop/desktop_native/core/Cargo.toml
+++ b/apps/desktop/desktop_native/core/Cargo.toml
@@ -54,7 +54,7 @@ bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", br
tokio = { version = "=1.40.0", features = ["io-util", "sync", "macros", "net"] }
tokio-stream = { version = "=0.1.15", features = ["net"] }
tokio-util = "=0.7.12"
-thiserror = "=1.0.68"
+thiserror = "=1.0.69"
typenum = "=1.17.0"
rand_chacha = "=0.3.1"
pkcs8 = { version = "=0.10.2", features = ["alloc", "encryption", "pem"] }
diff --git a/libs/importer/spec/netwrix-passwordsecure-csv-importer.spec.ts b/libs/importer/spec/netwrix-passwordsecure-csv-importer.spec.ts
new file mode 100644
index 00000000000..ab893dbc56c
--- /dev/null
+++ b/libs/importer/spec/netwrix-passwordsecure-csv-importer.spec.ts
@@ -0,0 +1,91 @@
+import { Utils } from "@bitwarden/common/platform/misc/utils";
+
+import { NetwrixPasswordSecureCsvImporter } from "../src/importers";
+
+import { credentialsData } from "./test-data/netwrix-csv/login-export.csv";
+
+describe("Netwrix Password Secure CSV Importer", () => {
+ let importer: NetwrixPasswordSecureCsvImporter;
+ beforeEach(() => {
+ importer = new NetwrixPasswordSecureCsvImporter();
+ });
+
+ it("passing invalid data returns false", async () => {
+ const result = await importer.parse("");
+ expect(result != null).toBe(true);
+ expect(result.success).toBe(false);
+ });
+
+ it("should parse login records", async () => {
+ const result = await importer.parse(credentialsData);
+ expect(result != null).toBe(true);
+
+ let cipher = result.ciphers.shift();
+ expect(cipher.name).toEqual("Test Entry 1");
+ expect(cipher.login.username).toEqual("someUser");
+ expect(cipher.login.password).toEqual("somePassword");
+ expect(cipher.login.totp).toEqual("someTOTPSeed");
+ expect(cipher.login.uris.length).toEqual(1);
+ let uriView = cipher.login.uris.shift();
+ expect(uriView.uri).toEqual("https://www.example.com");
+ expect(cipher.notes).toEqual("some note for example.com");
+
+ cipher = result.ciphers.shift();
+ expect(cipher.name).toEqual("Test Entry 2");
+ expect(cipher.login.username).toEqual("jdoe");
+ expect(cipher.login.password).toEqual("})9+Kg2fz_O#W1§H1-
0Zio");
+ expect(cipher.login.totp).toEqual("anotherTOTP");
+ expect(cipher.login.uris.length).toEqual(1);
+ uriView = cipher.login.uris.shift();
+ expect(uriView.uri).toEqual("http://www.123.com");
+ expect(cipher.notes).toEqual("Description123");
+
+ cipher = result.ciphers.shift();
+ expect(cipher.name).toEqual("Test Entry 3");
+ expect(cipher.login.username).toEqual("username");
+ expect(cipher.login.password).toEqual("password");
+ expect(cipher.login.totp).toBeNull();
+ expect(cipher.login.uris.length).toEqual(1);
+ uriView = cipher.login.uris.shift();
+ expect(uriView.uri).toEqual("http://www.internetsite.com");
+ expect(cipher.notes).toEqual("Information");
+ });
+
+ it("should add any unmapped fields as custom fields", async () => {
+ const result = await importer.parse(credentialsData);
+ expect(result != null).toBe(true);
+
+ const cipher = result.ciphers.shift();
+ expect(cipher.fields.length).toBe(1);
+ const field = cipher.fields.shift();
+ expect(field.name).toEqual("DataTags");
+ expect(field.value).toEqual("tag1, tag2, tag3");
+ });
+
+ it("should parse an item and create a folder", async () => {
+ const result = await importer.parse(credentialsData);
+
+ expect(result).not.toBeNull();
+ expect(result.success).toBe(true);
+ expect(result.folders.length).toBe(2);
+ expect(result.folders[0].name).toBe("folderOrCollection1");
+ expect(result.folders[1].name).toBe("folderOrCollection2");
+ expect(result.folderRelationships[0]).toEqual([0, 0]);
+ expect(result.folderRelationships[1]).toEqual([1, 1]);
+ expect(result.folderRelationships[2]).toEqual([2, 0]);
+ });
+
+ it("should parse an item and create a collection when importing into an organization", async () => {
+ importer.organizationId = Utils.newGuid();
+ const result = await importer.parse(credentialsData);
+
+ expect(result).not.toBeNull();
+ expect(result.success).toBe(true);
+ expect(result.collections.length).toBe(2);
+ expect(result.collections[0].name).toBe("folderOrCollection1");
+ expect(result.collections[1].name).toBe("folderOrCollection2");
+ expect(result.collectionRelationships[0]).toEqual([0, 0]);
+ expect(result.collectionRelationships[1]).toEqual([1, 1]);
+ expect(result.collectionRelationships[2]).toEqual([2, 0]);
+ });
+});
diff --git a/libs/importer/spec/test-data/netwrix-csv/login-export.csv.ts b/libs/importer/spec/test-data/netwrix-csv/login-export.csv.ts
new file mode 100644
index 00000000000..715dd8e0074
--- /dev/null
+++ b/libs/importer/spec/test-data/netwrix-csv/login-export.csv.ts
@@ -0,0 +1,4 @@
+export const credentialsData = `"Organisationseinheit";"DataTags";"Beschreibung";"Benutzername";"Passwort";"Internetseite";"Informationen";"One-Time Passwort"
+"folderOrCollection1";"tag1, tag2, tag3";"Test Entry 1";"someUser";"somePassword";"https://www.example.com";"some note for example.com";"someTOTPSeed"
+"folderOrCollection2";"tag2";"Test Entry 2";"jdoe";"})9+Kg2fz_O#W1§H1-0Zio";"www.123.com";"Description123";"anotherTOTP"
+"folderOrCollection1";"someTag";"Test Entry 3";"username";"password";"www.internetsite.com";"Information";""`;
diff --git a/libs/importer/src/components/import.component.html b/libs/importer/src/components/import.component.html
index 91ad7dbfc0a..5b67fc47a78 100644
--- a/libs/importer/src/components/import.component.html
+++ b/libs/importer/src/components/import.component.html
@@ -380,6 +380,10 @@
In the ProtonPass browser extension, go to Settings > Export. Export without PGP
encryption and save the zip file.
+
+ Open the FullClient, go to the Main Menu and select Export. Start the export passwords
+ wizard and follow the instructions to export a CSV file.
+
{
+ const result = new ImportResult();
+ const results = this.parseCsv(data, true);
+ if (results == null) {
+ result.success = false;
+ return Promise.resolve(result);
+ }
+
+ results.forEach((row: LoginRecord) => {
+ this.processFolder(result, row.Organisationseinheit);
+ const cipher = this.initLoginCipher();
+
+ const notes = this.getValueOrDefault(row.Informationen);
+ if (notes) {
+ cipher.notes = `${notes}\n`;
+ }
+
+ cipher.name = this.getValueOrDefault(row.Beschreibung, "--");
+ cipher.login.username = this.getValueOrDefault(row.Benutzername);
+ cipher.login.password = this.getValueOrDefault(row.Passwort);
+ cipher.login.uris = this.makeUriArray(row.Internetseite);
+
+ cipher.login.totp = this.getValueOrDefault(row["One-Time Passwort"]);
+
+ this.importUnmappedFields(cipher, row, _mappedColumns);
+
+ this.cleanupCipher(cipher);
+ result.ciphers.push(cipher);
+ });
+
+ if (this.organization) {
+ this.moveFoldersToCollections(result);
+ }
+
+ result.success = true;
+ return Promise.resolve(result);
+ }
+
+ private importUnmappedFields(cipher: CipherView, row: any, mappedValues: Set) {
+ const unmappedFields = Object.keys(row).filter((x) => !mappedValues.has(x));
+ unmappedFields.forEach((key) => {
+ const item = row as any;
+ this.processKvp(cipher, key, item[key]);
+ });
+ }
+}
diff --git a/libs/importer/src/importers/netwrix/netwrix-passwordsecure-csv-types.ts b/libs/importer/src/importers/netwrix/netwrix-passwordsecure-csv-types.ts
new file mode 100644
index 00000000000..63a4255805e
--- /dev/null
+++ b/libs/importer/src/importers/netwrix/netwrix-passwordsecure-csv-types.ts
@@ -0,0 +1,18 @@
+export class LoginRecord {
+ /** Organization unit / folder / collection */
+ Organisationseinheit: string;
+ /** Tags? */
+ DataTags: string;
+ /** Description/title */
+ Beschreibung: string;
+ /** Username */
+ Benutzername: string;
+ /** Password */
+ Passwort: string;
+ /** URL */
+ Internetseite: string;
+ /** Notes/additional information */
+ Informationen: string;
+ /** TOTP */
+ "One-Time Passwort": string;
+}
diff --git a/libs/importer/src/models/import-options.ts b/libs/importer/src/models/import-options.ts
index 64546cc57b3..f656c728ffd 100644
--- a/libs/importer/src/models/import-options.ts
+++ b/libs/importer/src/models/import-options.ts
@@ -70,6 +70,7 @@ export const regularImportOptions = [
{ id: "nordpasscsv", name: "Nordpass (csv)" },
{ id: "psonojson", name: "Psono (json)" },
{ id: "passkyjson", name: "Passky (json)" },
+ { id: "netwrixpasswordsecure", name: "Netwrix Password Secure (csv)" },
] as const;
export type ImportType =
diff --git a/libs/importer/src/services/import.service.ts b/libs/importer/src/services/import.service.ts
index 17695c29d57..6bfc5d5ce99 100644
--- a/libs/importer/src/services/import.service.ts
+++ b/libs/importer/src/services/import.service.ts
@@ -54,6 +54,7 @@ import {
MSecureCsvImporter,
MeldiumCsvImporter,
MykiCsvImporter,
+ NetwrixPasswordSecureCsvImporter,
NordPassCsvImporter,
OnePassword1PifImporter,
OnePassword1PuxImporter,
@@ -335,6 +336,8 @@ export class ImportService implements ImportServiceAbstraction {
return new PasskyJsonImporter();
case "protonpass":
return new ProtonPassJsonImporter(this.i18nService);
+ case "netwrixpasswordsecure":
+ return new NetwrixPasswordSecureCsvImporter();
default:
return null;
}