Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 48 additions & 9 deletions apps/desktop/src/components/vault/password-import-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { isGrantCancelled, useVaultGrant, withGrant } from "./grant";
import { isVaultLocked, toastApiError } from "./vault-utils";
import { WorkspaceSelect } from "./workspace-select";

type Source = "chrome" | "1password" | "other";
type Source = "chrome" | "1password" | "dashlane" | "other";
type Filter = "all" | "new" | "update" | "unchanged" | "conflict";

const SOURCE_NAMES: Record<PasswordImportSource, string> = {
Expand All @@ -38,7 +38,7 @@ const SOURCE_NAMES: Record<PasswordImportSource, string> = {
};

const GRANT_REASON = "Enter your vault passphrase to compare the export with your saved logins.";
const isExportFile = (f: File) => /\.(csv|1pux|txt)$/i.test(f.name) || f.type === "text/csv";
const isExportFile = (f: File) => /\.(csv|1pux|txt|zip|json)$/i.test(f.name) || f.type === "text/csv";
/** An export can be older than the vault, so replacing a saved password is always an explicit choice. */
const preselected = (r: PasswordImportRow) => r.action !== "unchanged" && !r.conflictKey && !r.replacesPassword;
const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString()} ${n === 1 ? one : many}`;
Expand Down Expand Up @@ -122,8 +122,12 @@ function ImportFlow({ defaultWorkspaceId, onClose }: { defaultWorkspaceId: strin

const readFile = (file: File) => {
if (preview.isPending) return;
if (/\.dash$/i.test(file.name)) {
setError("That's Dashlane's encrypted backup. In Dashlane, choose Export to CSV instead and drop the .zip here.");
return;
}
if (!isExportFile(file)) {
setError("Choose the .csv or .1pux file your password manager exported.");
setError("Choose the .csv, .zip or .1pux file your password manager exported.");
return;
}
preview.mutate({ file, scope: workspaceId });
Expand Down Expand Up @@ -169,7 +173,7 @@ function ImportFlow({ defaultWorkspaceId, onClose }: { defaultWorkspaceId: strin
? "Pick what to bring over. Logins you already saved are updated, not duplicated."
: step.kind === "done"
? `Finished importing ${step.file.name}.`
: "Bring your logins over from Chrome, 1Password or another password manager. Nothing is saved until you confirm."}
: "Bring your logins over from Chrome, 1Password, Dashlane or another password manager. Nothing is saved until you confirm."}
</DialogDescription>
</div>
</div>
Expand Down Expand Up @@ -248,10 +252,11 @@ function SourcePicker({ value, onChange }: { value: Source; onChange: (s: Source
const options: { id: Source; label: string; hint: string; mark: ReactNode }[] = [
{ id: "chrome", label: "Chrome", hint: "Edge, Brave & Arc too", mark: <ChromeMark /> },
{ id: "1password", label: "1Password", hint: ".1pux or .csv", mark: <OnePasswordMark /> },
{ id: "dashlane", label: "Dashlane", hint: ".zip or .csv", mark: <DashlaneMark /> },
{
id: "other",
label: "Other",
hint: "Bitwarden, Safari, Firefox…",
hint: "Bitwarden, Safari…",
mark: (
<span className="grid size-7 place-items-center rounded-full bg-secondary text-muted-foreground ring-1 ring-border ring-inset">
<FileSpreadsheet className="size-3.5" />
Expand All @@ -260,7 +265,7 @@ function SourcePicker({ value, onChange }: { value: Source; onChange: (s: Source
},
];
return (
<div role="radiogroup" aria-label="Import from" className="grid grid-cols-3 gap-2">
<div role="radiogroup" aria-label="Import from" className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{options.map((o) => {
const active = value === o.id;
return (
Expand Down Expand Up @@ -347,14 +352,39 @@ function SourceGuide({ source }: { source: Source }) {
<p className="border-t px-3.5 py-2.5 text-xs text-muted-foreground">Archived items, credit cards and notes are left out. 2FA codes are saved and linked to their login.</p>
</>
)}
{source === "dashlane" && (
<>
<ol className="space-y-2.5 p-3.5">
<GuideStep n={1}>
<span className="inline-flex flex-wrap items-center gap-1">
Open
<span className="inline-flex items-center rounded-md border bg-paper-2 py-px pr-0.5 pl-2 font-mono text-xs">
app.dashlane.com
<CopyButton value="https://app.dashlane.com" size="icon-xs" label="Copy address" toastLabel="Address copied — paste it into your browser" />
</span>
and sign in.
</span>
</GuideStep>
<GuideStep n={2}>
Open the <B>Vault</B> menu at the top left, choose <B>Settings → Export data</B> and click <B>Export to CSV</B>.
</GuideStep>
<GuideStep n={3}>
Drop the downloaded <B>.zip</B> below as it is — no need to unpack it.
</GuideStep>
</ol>
<p className="border-t px-3.5 py-2.5 text-xs text-muted-foreground">
2FA codes come along and are linked to their login. Notes, IDs and payment cards in the export are left out; Dashlane can't export passkeys.
</p>
</>
)}
{source === "other" && (
<>
<dl className="grid gap-x-6 gap-y-2 p-3.5 text-sm sm:grid-cols-2">
{[
["Bitwarden", "Tools → Export vault → .csv"],
["Apple Passwords", "File → Export All Passwords"],
["Firefox", "about:logins → ⋯ → Export Logins"],
["LastPass · Dashlane", "Advanced → Export → CSV"],
["LastPass", "Advanced → Export → CSV"],
["Proton Pass", "Settings → Export → CSV"],
["KeePassXC", "Database → Export → CSV"],
].map(([name, where]) => (
Expand Down Expand Up @@ -419,13 +449,13 @@ function ExportDropZone({ busy, onFile }: { busy: boolean; onFile: (file: File)
<div>
<p className="text-sm font-medium">{busy ? "Reading your export…" : dragging ? "Drop to read the file" : "Drop your export here"}</p>
<p className="mt-1 text-xs text-muted-foreground">
.csv or .1pux · or <span className="font-medium text-foreground underline-offset-2 group-hover:underline">browse files</span> · you'll review everything first
.csv, .zip or .1pux · or <span className="font-medium text-foreground underline-offset-2 group-hover:underline">browse files</span> · you'll review everything first
</p>
</div>
<input
ref={input}
type="file"
accept=".csv,.1pux,text/csv"
accept=".csv,.zip,.json,.1pux,.dash,text/csv,application/zip"
className="sr-only"
tabIndex={-1}
onChange={(e) => {
Expand Down Expand Up @@ -802,6 +832,15 @@ function ChromeMark() {
);
}

function DashlaneMark() {
return (
<svg viewBox="0 0 24 24" className="size-7 shrink-0" aria-hidden>
<circle cx="12" cy="12" r="11" fill="#0E353D" />
<path d="M8 6.5h4.1c3.3 0 5.4 2.2 5.4 5.5s-2.1 5.5-5.4 5.5H8Zm2.6 2.3v6.4h1.4c1.8 0 2.8-1.2 2.8-3.2s-1-3.2-2.8-3.2Z" fill="#fff" fillRule="evenodd" />
</svg>
);
}

function OnePasswordMark() {
return (
<svg viewBox="0 0 24 24" className="size-7 shrink-0" aria-hidden>
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/pages/vault/logins-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ export default function LoginsPage() {
<div className="flex flex-col items-center gap-3">
<div className="flex flex-wrap justify-center gap-2">
<Button onClick={() => setImportOpen(true)}>
<FileKey /> Import from Chrome or 1Password
<FileKey /> Import from Chrome, 1Password or Dashlane
</Button>
<Button variant="outline" onClick={openCreate}>
<Plus /> Add a login
Expand Down
2 changes: 1 addition & 1 deletion apps/website/src/components/sections/Capabilities.astro
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ import Character from '../ui/Character.astro';
<p class="mt-2.5 text-[15px] leading-relaxed text-muted">
Passwords and 2FA codes stay encrypted on your computer. Godmode types them into the login page
<em>for</em> the AI, which only ever sees <span class="font-mono text-[13px] text-text-2">[secret hidden]</span>.
Bring them over from Chrome, 1Password, Bitwarden or a screenshot of your authenticator QR code.
Bring them over from Chrome, 1Password, Dashlane, Bitwarden or a screenshot of your authenticator QR code.
</p>
</div>
<div class="vault-demo self-center" aria-hidden="true">
Expand Down
2 changes: 1 addition & 1 deletion apps/website/src/components/sections/HowItWorks.astro
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const steps = [
icon: 'key-round',
title: 'Give it access',
time: '5 minutes',
text: 'Bring over your Chrome logins and your passwords from Chrome or 1Password, and your 2FA codes from a screenshot. All encrypted, all on your computer.',
text: 'Bring over your Chrome logins and your passwords from Chrome, 1Password or Dashlane, and your 2FA codes from a screenshot. All encrypted, all on your computer.',
},
{
n: '3',
Expand Down
2 changes: 1 addition & 1 deletion docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Everything Godmode does, the screenshots, and the setup guides for each part. Ba
| **Plugins** | Claude Code plugins for your agents. Install whole plugins from GitHub in one click — [Claude Video](https://github.com/bradautomates/claude-video) lets agents watch videos, [oh-my-claudecode](https://github.com/Yeachan-Heo/oh-my-claudecode) adds multi-agent workflows — or any other repository by its link, and switch them off, update or uninstall them just as easily. Small ones ([Claude Code mods](https://claude.com/blog/claude-code-mods)) run inside every turn: refuse risky shell commands, keep agents away from `.env` files, mask keys in tool output, cap runaway turns, expand `!shortcuts` — add one Godmode made, write your own in the built-in editor or let Godmode draft it. Every plugin is checked by Claude Code's own validator, and Godmode shows what it brings and what it can reach before you switch it on. |
| **Chrome session import** | Continue where Chrome left off — import cookies from your Chrome/Edge/Brave/Helium profile (profile-use technique), or sync via browser-use `profile-use`. |
| **Vault** | Logins with password generator, per-workspace or global, AES-256-GCM encrypted, fully audited. |
| **Password import** | Bring logins over from Chrome (and Edge, Brave, Arc), 1Password (.1pux or CSV), Bitwarden, Apple Passwords, Firefox and more — with a preview that updates saved logins instead of duplicating them. |
| **Password import** | Bring logins over from Chrome (and Edge, Brave, Arc), 1Password (.1pux or CSV), Dashlane (zip of CSVs, 2FA included), Bitwarden, Apple Passwords, Firefox and more — with a preview that updates saved logins instead of duplicating them. |
| **2FA / TOTP** | Import Google Authenticator QR codes from screenshots — including multi-account *export* QR codes — or scan with your camera. |
| **Missing-login inbox** | Agents report missing or broken logins, accounts and 2FA; add them in one click. |
| **Integrations** | Composio toolkits (Gmail, Slack, GitHub, Notion…) and custom MCP servers — globally, per workspace, or per agent. |
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
49 changes: 42 additions & 7 deletions packages/core/src/vault/passwordImport.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Password imports: CSV exports of Chrome (and every Chromium browser), 1Password, Bitwarden, Apple Passwords,
* Firefox, LastPass, Dashlane, Proton Pass and KeePass, plus 1Password's .1pux archive.
* Firefox, LastPass, Dashlane, Proton Pass and KeePass, plus 1Password's .1pux archive and Dashlane's zip and JSON exports.
*
* Rows for the same site + username + password become one login with several domains. Every login is matched
* against the saved logins of the target scope, so importing the same export twice changes nothing.
Expand Down Expand Up @@ -96,20 +96,20 @@ export function parseCsv(text: string, sep = ","): string[][] {
/** Header aliases, most specific first. */
const COLUMNS = {
url: ["url", "login_uri", "login uri", "website", "web site", "web address", "login url", "uri", "origin_url", "hostname"],
username: ["username", "login_username", "user name", "login name", "login", "user", "email", "e-mail", "email address"],
username: ["username", "login_username", "user name", "login name", "login", "user", "email", "e-mail", "email address", "username2", "username3"],
password: ["password", "login_password"],
name: ["name", "title"],
notes: ["note", "notes", "extra", "comment", "comments"],
otp: ["otpauth", "login_totp", "totp", "otpsecret", "otp", "one-time password"],
tags: ["tags", "folder", "group", "grouping"],
otp: ["otpauth", "login_totp", "totp", "otpurl", "otpsecret", "otp", "one-time password"],
tags: ["tags", "folder", "group", "grouping", "category"],
archived: ["archived"],
} as const;

function detectCsvSource(h: Set<string>): PasswordImportSource {
if (h.has("login_uri")) return "bitwarden";
if (h.has("httprealm") || h.has("formactionorigin")) return "firefox";
if (h.has("grouping") && h.has("extra")) return "lastpass";
if (h.has("username2") || h.has("otpsecret")) return "dashlane";
if (h.has("username2") || h.has("otpsecret") || h.has("otpurl")) return "dashlane";
if (h.has("vault") && h.has("createtime")) return "protonpass";
if (h.has("group") && h.has("title")) return "keepass";
if (h.has("otpauth") && (h.has("archived") || h.has("favorite"))) return "1password";
Expand Down Expand Up @@ -226,15 +226,50 @@ export function parse1PuxData(json: string): ParsedExport {
return { source: "1password", entries };
}

/* ------------------------------------------------------------------ */
/* Dashlane JSON (older desktop app) */
/* ------------------------------------------------------------------ */

type DashlaneLogin = { title?: unknown; domain?: unknown; url?: unknown; login?: unknown; email?: unknown; secondaryLogin?: unknown; password?: unknown; note?: unknown };

export function parseDashlaneJson(json: string): ParsedExport | null {
let data: unknown;
try {
data = JSON.parse(json);
} catch {
return null;
}
const logins = (data as { AUTHENTIFIANT?: unknown } | null)?.AUTHENTIFIANT;
if (!Array.isArray(logins)) return null;
return {
source: "dashlane",
entries: list<DashlaneLogin>(logins).map((l) => ({
name: str(l.title).trim(),
url: (str(l.url) || str(l.domain)).trim(),
username: [l.login, l.email, l.secondaryLogin].map((v) => str(v).trim()).find(Boolean) ?? "",
password: str(l.password),
notes: str(l.note).trim(),
otp: "",
tags: [],
})),
};
}

const isZip = (data: Uint8Array) => data[0] === 0x50 && data[1] === 0x4b && data[2] === 0x03 && data[3] === 0x04;
const tooLarge = (what: string) => new HttpError(413, `${what} is larger than 64 MB`, "too_large");

/** Read an uploaded export: a CSV in any supported layout, a .1pux archive, or Dashlane's zip of CSVs. */
/** Read an uploaded export: a CSV in any supported layout, a .1pux archive, or Dashlane's zip of CSVs or JSON. */
export function readPasswordExport(data: Uint8Array): ParsedExport {
if (data.length === 0) throw badRequest("The file is empty");
if (!isZip(data)) {
if (data.length > MAX_TEXT_BYTES) throw tooLarge("The CSV");
return parsePasswordCsv(decodeText(data));
const text = decodeText(data).replace(/^\uFEFF/, "");
if (/^\s*\{/.test(text)) {
const dashlane = parseDashlaneJson(text);
if (dashlane) return dashlane;
throw badRequest("This JSON file is not a Dashlane export. Export a CSV from your password manager instead");
}
return parsePasswordCsv(text);
}
const wanted = (name: string) => name === "export.data" || /(^|\/)credentials\.csv$/i.test(name);
let oversized = false;
Expand Down
35 changes: 35 additions & 0 deletions packages/core/test/password-import.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,41 @@ describe("1Password .1pux", () => {
expect(readPasswordExport(zip)).toMatchObject({ source: "dashlane", entries: [{ name: "Jira", password: "pw" }] });
});

test("Dashlane's current CSV: otpUrl, second usernames and categories", () => {
const csv =
"username,username2,username3,title,password,note,url,category,otpUrl\n" +
"me@x.de,,,GitHub,pw1,,https://github.com,Work,otpauth://totp/GitHub:me?secret=JBSWY3DPEHPK3PXP&issuer=GitHub\n" +
",alt@x.de,,Shop,pw2,,shop.example.com,,\n";
const zip = zipSync({ "dashlane-credential-export/credentials.csv": strToU8(csv), "dashlane-credential-export/securenotes.csv": strToU8("title,note\nx,y\n") });
const parsed = readPasswordExport(zip);
expect(parsed.source).toBe("dashlane");
expect(parsed.entries).toMatchObject([
{ name: "GitHub", username: "me@x.de", tags: ["Work"] },
{ name: "Shop", username: "alt@x.de" },
]);
const { logins } = groupLogins(parsed.entries);
expect(logins.find((l) => l.name === "GitHub")!.totp).toMatchObject({ issuer: "GitHub", secret: "JBSWY3DPEHPK3PXP" });
});

test("Dashlane's JSON export", () => {
const json = JSON.stringify({
AUTHENTIFIANT: [
{ title: "Amazon", domain: "amazon.de", login: "", email: "me@x.de", password: "pw", note: "prime" },
{ title: "Router", domain: "192.168.0.1", login: "admin", password: "pw" },
],
PAYMENTMEANS_CREDITCARD: [{ cardNumber: "4111" }],
});
const parsed = readPasswordExport(bytes(`\uFEFF${json}`));
expect(parsed).toMatchObject({ source: "dashlane", entries: [{ name: "Amazon", url: "amazon.de", username: "me@x.de", notes: "prime" }, { name: "Router" }] });
const { logins, skipped } = groupLogins(parsed.entries);
expect(logins.map((l) => l.hosts[0])).toEqual(["amazon.de"]);
expect(skipped[0]!.reason).toBe("Local network address");
});

test("other JSON files are refused", () => {
expect(() => readPasswordExport(bytes(`{"items":[]}`))).toThrow(/not a Dashlane export/);
});

test("Windows-1252 CSVs keep their accents", () => {
const latin = new Uint8Array([...bytes("name,url,username,password\nx,https://x.de,me,caf"), 0xe9, 0x0a]);
expect(readPasswordExport(latin).entries[0]!.password).toBe("café");
Expand Down
Loading