diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c3563e0..72d0193 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -135,18 +135,11 @@ jobs:
- name: Build web
run: npm run build --workspace=@passman/web
- # NOTE: @passman/extension's `build` script invokes `vite build`, but
- # the package has no vite.config.ts. Vite falls back to SPA mode and
- # fails with "Could not resolve entry module 'index.html'". Producing
- # a loadable MV3 extension needs a multi-entry config (background,
- # content, popup) plus a static-copy plugin for manifest.json. That
- # is dedicated work tracked for a follow-up; until then, the
- # extension's correctness is enforced by:
- # - `tsc --noEmit` in the typecheck step above
- # - the @crxjs/vite-plugin or equivalent migration (Phase 2)
- # Skipping `npm run build --workspace=@passman/extension` here
- # rather than masking it with `|| true` keeps the failure visible
- # and the CI signal honest.
+ # The extension now ships with a Vite multi-entry config (background,
+ # content, popup) plus a manifest-copy plugin. Building here verifies
+ # the bundle Chrome would `Load unpacked` against doesn't regress.
+ - name: Build extension
+ run: npm run build --workspace=@passman/extension
- name: Test (all workspaces)
run: npm run test --workspaces --if-present
diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md
index 0f009d8..140ab5f 100644
--- a/docs/USER_GUIDE.md
+++ b/docs/USER_GUIDE.md
@@ -268,15 +268,108 @@ When ticked:
The toggle defaults to off, so existing zero-knowledge guarantees apply
to everyone who doesn't opt in.
+### 5b. Generate a strong password
+
+Click **Generate** next to the Password field to open the inline generator:
+
+- **Length** slider (6–64). Default 20.
+- Toggles for `a–z`, `A–Z`, `0–9`, symbols, and *No 0/O/1/l* (drops
+ visually-confusable characters — useful for passwords you'll read aloud).
+- Live **strength meter** showing entropy bits and a label
+ (`weak` / `fair` / `strong` / `excellent`) — backed by Shannon entropy
+ (length × log₂(alphabet)).
+- **Use this password** stamps the value into the Password field; **↻**
+ re-rolls without closing the popover.
+
+The CSPRNG is the browser's `crypto.getRandomValues`. Rejection sampling
+ensures every char in the alphabet has equal probability — `% length`
+would skew toward the start of the alphabet for non-power-of-two charsets.
+
+Your last-used options persist to localStorage so you don't re-tick the
+same boxes every time.
+
+### 5c. Import from another vault
+
+Click **↥ Import** in the page header to open the import dialog. v1
+supports **Bitwarden CSV** (the most common export format).
+
+In Bitwarden: **Tools → Export vault → File format CSV**. The export
+contains plaintext passwords — keep the file on this device, import it
+in Passman, and delete it.
+
+The dialog parses the file, shows you a preview of the importable rows
+plus any rows it skipped (with reasons), and lets you confirm. There's
+also a **Store all imported items on this device only** toggle if you
+want the whole batch local-only.
+
+What gets mapped: `name`, `login_username`, `login_password`,
+`login_uri`, `notes`, `login_totp`. Folders and unknown columns are
+silently dropped. Non-login items (notes, cards, identities) are
+skipped — Passman only stores logins today.
+
+### 5d. SSH private-key field
+
+For SSH-protocol credentials, the form grows an **SSH private key
+(PEM)** textarea below the URL field. Paste any of:
+
+- `-----BEGIN OPENSSH PRIVATE KEY-----`
+- `-----BEGIN RSA PRIVATE KEY-----`
+- `-----BEGIN EC PRIVATE KEY-----`
+- `-----BEGIN PRIVATE KEY-----`
+
+The key is encrypted with the same vault key as the password — the
+server never sees its contents. Once attached, the Connect dialog's
+**Connect with SSH key** option becomes active: it downloads the key
+as `.pem` and copies a matching command to the clipboard:
+
+```
+ssh -i ~/.ssh/passman/.pem user@host [-p port]
+```
+
+Move the downloaded `.pem` into `~/.ssh/passman/` (chmod `600`), paste
+the command, and connect.
+
+### 5e. Edit an existing credential
+
+Hover any row → click the **✎** icon next to the delete button. The
+form opens in Edit mode with all current fields pre-filled. The
+storage-location toggle is hidden (you can't move items between stores
+from the form — that's a separate operation we deliberately defer).
+Click **Save changes** to re-encrypt and persist.
+
+For local-only items, the update writes a new IndexedDB record. For
+server items, it issues `PATCH /api/vault/items/:id`.
+
+### 5f. Export your vault
+
+Click **Export backup** in the user card at the bottom of the sidebar.
+Passman builds a JSON file containing every encrypted blob (server +
+local) plus metadata, then downloads it as
+`passman--.json`. The file is unreadable without your
+master password — the vault key never leaves the browser.
+
+Use this for: disaster-recovery snapshots, migration between
+self-hosted instances, before clearing site data.
+
+The file shape is intentionally stable (`{ format, version, vault,
+items: [...] }`) so a future Restore flow can consume it without a
+schema bump.
+
---
## 6. Chrome extension
-> ⚠️ The extension's runtime code typechecks cleanly, but a loadable
-> Chrome bundle requires a Vite multi-entry config that is tracked for a
-> follow-up. The screenshots below show the popup as it will appear once
-> the extension is loadable. The web vault remains the primary client
-> for now.
+The extension is now buildable and loadable. From the repo root:
+
+```bash
+npm install
+npm run build --workspace=@passman/core # core types must build first
+npm run build --workspace=@passman/extension # produces packages/extension/dist/
+```
+
+Then in Chrome / Edge / Brave: `chrome://extensions` → toggle
+**Developer mode** → **Load unpacked** → pick
+`packages/extension/dist/`. The Passman icon appears in your toolbar.
### 6a. Locked popup
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 754beb5..dc1ec53 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -66,6 +66,13 @@ export interface VaultLoginPlaintext {
domain?: string;
/** Free-form environment label shown as a row tag (e.g. "prod", "staging"). */
environment?: string;
+ /**
+ * SSH private key (PEM-encoded). When set the Connect dialog offers a
+ * "Use SSH key" action that downloads the key as `.pem`. The blob
+ * is encrypted with the same vault key as the rest of the credential —
+ * the server never sees its contents.
+ */
+ privateKey?: string;
notes?: string;
/** otpauth:// URI for TOTP, optional. */
totp?: string;
diff --git a/packages/extension/vite.config.ts b/packages/extension/vite.config.ts
new file mode 100644
index 0000000..dd1f04b
--- /dev/null
+++ b/packages/extension/vite.config.ts
@@ -0,0 +1,82 @@
+/**
+ * Vite config that produces a Chrome-loadable Manifest V3 bundle.
+ *
+ * The MV3 platform has three independently-loaded JS contexts: the
+ * service worker (background), the content script, and the popup. They
+ * can't share a Rollup chunk graph the way a single SPA can, so we run
+ * Vite once with three separate `input` entries and force `inlineDynamicImports`.
+ *
+ * `manifest.json` and `popup/index.html` are copied verbatim from `src/`
+ * to `dist/` by the `copy-manifest` plugin below; the manifest's `js`
+ * fields then resolve against the flat `dist/` layout that
+ * `chrome://extensions → Load unpacked` expects.
+ */
+
+import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { defineConfig, type Plugin } from "vite";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const SRC = resolve(__dirname, "src");
+const OUT = resolve(__dirname, "dist");
+
+/**
+ * Copy `manifest.json` to dist/ and rewrite the popup HTML so its script
+ * src points at `popup.js` (the bundled, no-extension form Vite emits).
+ */
+function copyExtensionAssets(): Plugin {
+ return {
+ name: "passman-extension-assets",
+ apply: "build",
+ closeBundle() {
+ if (!existsSync(OUT)) mkdirSync(OUT, { recursive: true });
+
+ // 1. manifest.json — straight copy, no transform.
+ copyFileSync(
+ resolve(__dirname, "manifest.json"),
+ resolve(OUT, "manifest.json"),
+ );
+
+ // 2. popup/index.html — rewrite the module script src to the bundled
+ // `popup.js` that Vite produced (which lives at the dist root).
+ const popupSrc = readFileSync(resolve(SRC, "popup/index.html"), "utf8");
+ const popupOut = popupSrc.replace(
+ /src="\.\/popup\.ts"/,
+ 'src="./popup.js"',
+ );
+ writeFileSync(resolve(OUT, "popup.html"), popupOut);
+ },
+ };
+}
+
+export default defineConfig({
+ publicDir: false,
+ build: {
+ outDir: OUT,
+ emptyOutDir: true,
+ target: "esnext",
+ sourcemap: true,
+ minify: false,
+ // Chrome extension contexts can't share a runtime, so each entry is
+ // bundled independently with its dynamic imports inlined.
+ rollupOptions: {
+ input: {
+ background: resolve(SRC, "background.ts"),
+ content: resolve(SRC, "content.ts"),
+ popup: resolve(SRC, "popup/popup.ts"),
+ },
+ output: {
+ entryFileNames: "[name].js",
+ chunkFileNames: "[name].js",
+ assetFileNames: "[name][extname]",
+ format: "es",
+ inlineDynamicImports: false,
+ manualChunks: undefined,
+ },
+ preserveEntrySignatures: false,
+ },
+ },
+ plugins: [copyExtensionAssets()],
+});
diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts
index c7dbd18..7b5a0da 100644
--- a/packages/web/src/api/client.ts
+++ b/packages/web/src/api/client.ts
@@ -106,6 +106,16 @@ export const api = {
body: JSON.stringify(body),
}, token),
+ updateItem: (
+ token: string,
+ id: string,
+ body: { item_type?: string; encrypted_data?: string },
+ ) =>
+ request(`/vault/items/${id}`, {
+ method: "PATCH",
+ body: JSON.stringify(body),
+ }, token),
+
deleteItem: (token: string, id: string) =>
request(`/vault/items/${id}`, { method: "DELETE" }, token),
};
diff --git a/packages/web/src/backup/export.ts b/packages/web/src/backup/export.ts
new file mode 100644
index 0000000..b7f5ca7
--- /dev/null
+++ b/packages/web/src/backup/export.ts
@@ -0,0 +1,77 @@
+/**
+ * Backup-export builder.
+ *
+ * The exported file is plain JSON containing **already-encrypted**
+ * ciphertext blobs — the same envelopes the server stores, plus the local
+ * IndexedDB items, plus enough metadata for a future Restore flow to know
+ * where each item belongs. The user's master password is required to
+ * decrypt; it is NOT in the file.
+ *
+ * Shape (`PassmanBackupV1`):
+ * {
+ * "format": "passman-backup",
+ * "version": 1,
+ * "exportedAt": ISO timestamp,
+ * "vault": "user@example.az",
+ * "items": [
+ * { id, item_type, encrypted_data, location: "server" | "local",
+ * created_at, updated_at }
+ * ]
+ * }
+ */
+import type { LocatedItem } from "../storage/index.js";
+
+export interface PassmanBackupV1 {
+ format: "passman-backup";
+ version: 1;
+ exportedAt: string;
+ vault: string;
+ items: Array<{
+ id: string;
+ item_type: string;
+ encrypted_data: string;
+ location: "server" | "local";
+ created_at: string;
+ updated_at: string;
+ }>;
+}
+
+export function buildBackup(
+ vault: string,
+ items: LocatedItem[],
+): PassmanBackupV1 {
+ return {
+ format: "passman-backup",
+ version: 1,
+ exportedAt: new Date().toISOString(),
+ vault: vault.toLowerCase(),
+ items: items.map((it) => ({
+ id: it.id,
+ item_type: it.item_type,
+ encrypted_data: it.encrypted_data,
+ location: it.location,
+ created_at: it.created_at,
+ updated_at: it.updated_at,
+ })),
+ };
+}
+
+/**
+ * Serialise the backup as JSON and trigger a browser download. The file
+ * is named `passman--.json` with the vault email
+ * sanitised to filesystem-safe chars.
+ */
+export function downloadBackup(backup: PassmanBackupV1): void {
+ const json = JSON.stringify(backup, null, 2);
+ const blob = new Blob([json], { type: "application/json" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ const safeVault = backup.vault.replace(/[^a-zA-Z0-9_.-]+/g, "-");
+ const date = backup.exportedAt.slice(0, 10).replace(/-/g, "");
+ a.download = `passman-${safeVault}-${date}.json`;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+}
diff --git a/packages/web/src/backup/index.ts b/packages/web/src/backup/index.ts
new file mode 100644
index 0000000..f477594
--- /dev/null
+++ b/packages/web/src/backup/index.ts
@@ -0,0 +1 @@
+export { buildBackup, downloadBackup, type PassmanBackupV1 } from "./export.js";
diff --git a/packages/web/src/connect/index.ts b/packages/web/src/connect/index.ts
index f4dc889..4346b1b 100644
--- a/packages/web/src/connect/index.ts
+++ b/packages/web/src/connect/index.ts
@@ -10,5 +10,10 @@ export {
export { buildJdbcUrl, supportsJdbc } from "./jdbc.js";
export { buildConnectCommand } from "./command.js";
export { buildSshUrl, launchSshUrl } from "./ssh.js";
+export {
+ buildSshKeyCommand,
+ downloadSshKey,
+ looksLikePem,
+} from "./sshkey.js";
export { buildRdpFile, downloadRdpFile } from "./rdp.js";
export { copySensitive, copyPlain, CLIPBOARD_CLEAR_MS } from "./clipboard.js";
diff --git a/packages/web/src/connect/sshkey.ts b/packages/web/src/connect/sshkey.ts
new file mode 100644
index 0000000..76fdcef
--- /dev/null
+++ b/packages/web/src/connect/sshkey.ts
@@ -0,0 +1,56 @@
+import type { VaultLoginPlaintext } from "@passman/core";
+
+/**
+ * Loose validation for a PEM-encoded private key. We don't try to parse
+ * the key — that needs a real ASN.1 / OpenSSH parser — we just check
+ * the begin/end marker bracketing so a paste error is caught early.
+ *
+ * Accepts any of:
+ * -----BEGIN OPENSSH PRIVATE KEY-----
+ * -----BEGIN RSA PRIVATE KEY-----
+ * -----BEGIN EC PRIVATE KEY-----
+ * -----BEGIN PRIVATE KEY-----
+ */
+const PEM_RE =
+ /-----BEGIN ([A-Z]+ )?PRIVATE KEY-----[\s\S]+?-----END \1?PRIVATE KEY-----/;
+
+export function looksLikePem(text: string): boolean {
+ if (!text) return false;
+ return PEM_RE.test(text.trim());
+}
+
+/**
+ * Trigger a browser download of the credential's SSH key with mode-0600
+ * intent embedded in the filename. Returns false if no key is set.
+ */
+export function downloadSshKey(item: VaultLoginPlaintext): boolean {
+ if (!item.privateKey) return false;
+ // Always emit a trailing newline — `ssh -i` and OpenSSH itself reject
+ // some keys without one.
+ const content = item.privateKey.endsWith("\n")
+ ? item.privateKey
+ : `${item.privateKey}\n`;
+ const blob = new Blob([content], { type: "application/x-pem-file" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ const safeName = (item.name || "passman-key").replace(/[^a-zA-Z0-9_-]+/g, "-");
+ a.download = `${safeName}.pem`;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ return true;
+}
+
+/** Build an `ssh -i ~/.ssh/passman/.pem user@host` command for the
+ * copy-command action when a key is attached. */
+export function buildSshKeyCommand(item: VaultLoginPlaintext): string | null {
+ const host = item.hostname || item.ip;
+ if (!host || !item.privateKey) return null;
+ const safeName = (item.name || "passman-key").replace(/[^a-zA-Z0-9_-]+/g, "-");
+ const userAt = item.username ? `${item.username}@` : "";
+ const portArg =
+ item.port && item.port !== 22 ? ` -p ${item.port}` : "";
+ return `ssh -i ~/.ssh/passman/${safeName}.pem ${userAt}${host}${portArg}`;
+}
diff --git a/packages/web/src/import/bitwarden.ts b/packages/web/src/import/bitwarden.ts
new file mode 100644
index 0000000..f5ebaf5
--- /dev/null
+++ b/packages/web/src/import/bitwarden.ts
@@ -0,0 +1,102 @@
+import type { VaultLoginPlaintext } from "@passman/core";
+
+import { parseCsv } from "./csv.js";
+
+/**
+ * Bitwarden's "Bitwarden (csv)" export format. Header columns vary slightly
+ * across versions; we look up by name (case-insensitive), not column index,
+ * so newer/older exports both parse cleanly. Folders and unknown columns
+ * are silently dropped.
+ *
+ * The columns we care about (from a 2024 Bitwarden web vault export):
+ * folder,favorite,type,name,notes,fields,reprompt,login_uri,login_username,
+ * login_password,login_totp
+ *
+ * Items with `type` other than "login" are skipped — Passman only stores
+ * login records today.
+ */
+
+export interface ImportCandidate {
+ plaintext: VaultLoginPlaintext;
+ /** Source row index (1-based, header excluded) — used for error messages. */
+ sourceRow: number;
+}
+
+export interface ImportResult {
+ candidates: ImportCandidate[];
+ /** Rows we couldn't or wouldn't import, with a reason. */
+ skipped: { sourceRow: number; reason: string }[];
+}
+
+export function parseBitwardenCsv(text: string): ImportResult {
+ const rows = parseCsv(text);
+ if (rows.length === 0) {
+ return { candidates: [], skipped: [] };
+ }
+ const header = rows[0]!.map((h) => h.trim().toLowerCase());
+ const idx = (name: string) => header.indexOf(name);
+
+ const iType = idx("type");
+ const iName = idx("name");
+ const iNotes = idx("notes");
+ const iUri = idx("login_uri");
+ const iUser = idx("login_username");
+ const iPw = idx("login_password");
+ const iTotp = idx("login_totp");
+
+ if (iName === -1 || iPw === -1) {
+ // Best-effort — if name + password aren't present this isn't a Bitwarden CSV.
+ return {
+ candidates: [],
+ skipped: [
+ {
+ sourceRow: 0,
+ reason:
+ "Missing required columns. Expected 'name' and 'login_password' (Bitwarden CSV format).",
+ },
+ ],
+ };
+ }
+
+ const candidates: ImportCandidate[] = [];
+ const skipped: { sourceRow: number; reason: string }[] = [];
+
+ for (let i = 1; i < rows.length; i++) {
+ const r = rows[i]!;
+ const cell = (col: number) => (col >= 0 && col < r.length ? r[col]! : "");
+ const type = (cell(iType) || "login").toLowerCase();
+ if (type !== "login" && type !== "1") {
+ skipped.push({
+ sourceRow: i,
+ reason: `Skipped non-login item (type="${type}")`,
+ });
+ continue;
+ }
+ const name = cell(iName).trim();
+ if (!name) {
+ skipped.push({ sourceRow: i, reason: "Missing name" });
+ continue;
+ }
+ const password = cell(iPw);
+ if (!password) {
+ skipped.push({ sourceRow: i, reason: `Skipped "${name}" — empty password` });
+ continue;
+ }
+ const username = cell(iUser);
+ const url = cell(iUri).trim();
+ const notes = cell(iNotes);
+ const totp = cell(iTotp).trim();
+
+ const plaintext: VaultLoginPlaintext = {
+ name,
+ username,
+ password,
+ ...(url ? { url } : {}),
+ ...(notes ? { notes } : {}),
+ ...(totp ? { totp } : {}),
+ };
+ candidates.push({ plaintext, sourceRow: i });
+ }
+
+ return { candidates, skipped };
+}
diff --git a/packages/web/src/import/csv.ts b/packages/web/src/import/csv.ts
new file mode 100644
index 0000000..937ec60
--- /dev/null
+++ b/packages/web/src/import/csv.ts
@@ -0,0 +1,59 @@
+/**
+ * RFC 4180-flavoured CSV parser. Hand-rolled because the import path is
+ * small (one Bitwarden export at a time) and pulling `papaparse` for ~80
+ * lines of logic isn't a fair trade on a security-conscious app.
+ *
+ * Handles:
+ * - quoted fields with embedded commas/newlines
+ * - escaped quotes (`""` → `"`)
+ * - LF and CRLF line endings
+ * - empty trailing rows (a Bitwarden export ends with a newline)
+ *
+ * Returns an array of rows; each row is an array of cells. The header is
+ * the first row; the caller is responsible for mapping it.
+ */
+export function parseCsv(input: string): string[][] {
+ const rows: string[][] = [];
+ let row: string[] = [];
+ let cell = "";
+ let inQuotes = false;
+
+ for (let i = 0; i < input.length; i++) {
+ const c = input[i];
+ if (inQuotes) {
+ if (c === '"') {
+ if (input[i + 1] === '"') {
+ cell += '"';
+ i++;
+ } else {
+ inQuotes = false;
+ }
+ } else {
+ cell += c;
+ }
+ } else {
+ if (c === '"') {
+ inQuotes = true;
+ } else if (c === ",") {
+ row.push(cell);
+ cell = "";
+ } else if (c === "\n" || c === "\r") {
+ // Treat \r\n as one terminator.
+ if (c === "\r" && input[i + 1] === "\n") i++;
+ row.push(cell);
+ cell = "";
+ // Skip empty rows from trailing newlines.
+ if (row.length > 1 || row[0] !== "") rows.push(row);
+ row = [];
+ } else {
+ cell += c;
+ }
+ }
+ }
+ // Flush a trailing partial row that didn't end with a newline.
+ if (cell !== "" || row.length > 0) {
+ row.push(cell);
+ if (row.length > 1 || row[0] !== "") rows.push(row);
+ }
+ return rows;
+}
diff --git a/packages/web/src/import/index.ts b/packages/web/src/import/index.ts
new file mode 100644
index 0000000..bcd78ff
--- /dev/null
+++ b/packages/web/src/import/index.ts
@@ -0,0 +1,6 @@
+export { parseCsv } from "./csv.js";
+export {
+ parseBitwardenCsv,
+ type ImportCandidate,
+ type ImportResult,
+} from "./bitwarden.js";
diff --git a/packages/web/src/pages/VaultPage.tsx b/packages/web/src/pages/VaultPage.tsx
index 70447ac..c1f0029 100644
--- a/packages/web/src/pages/VaultPage.tsx
+++ b/packages/web/src/pages/VaultPage.tsx
@@ -8,6 +8,7 @@ import {
} from "@passman/core";
import { api } from "../api/client.js";
+import { buildBackup, downloadBackup } from "../backup/index.js";
import {
effectiveProtocol,
protocolLabel,
@@ -16,6 +17,7 @@ import {
createItem as createStored,
deleteItem as deleteStored,
listAll as listAllStored,
+ updateItem as updateStored,
type StorageLocation,
} from "../storage/index.js";
import { useSession } from "../stores/session.js";
@@ -23,6 +25,7 @@ import { AddCredentialForm } from "./vault/AddCredentialForm.js";
import { ConnectDialog } from "./vault/ConnectDialog.js";
import { CredentialsGrid } from "./vault/CredentialsGrid.js";
import { IconLock, IconSearch } from "./vault/icons.js";
+import { ImportDialog } from "./vault/ImportDialog.js";
import { markUsed } from "./vault/lastUsed.js";
import { Sidebar, type SidebarScope } from "./vault/Sidebar.js";
import type { DecryptedItem } from "./vault/types.js";
@@ -77,6 +80,8 @@ export function VaultPage() {
const [selected, setSelected] = useState>(new Set());
const [connectingItem, setConnectingItem] = useState(null);
const [adding, setAdding] = useState(false);
+ const [editingItem, setEditingItem] = useState(null);
+ const [importing, setImporting] = useState(false);
const [toast, setToast] = useState(null);
const toastTimer = useRef(null);
@@ -151,6 +156,54 @@ export function VaultPage() {
);
}
+ /** One-shot save used by the Import dialog — doesn't refresh the grid
+ * between imports so a 200-row import doesn't trigger 200 re-loads. */
+ async function onImportOne(
+ plaintext: VaultLoginPlaintext,
+ location: StorageLocation,
+ ) {
+ if (!accessToken || !vault || !email) return;
+ const encoded = await encryptVaultLogin(plaintext, vault);
+ await createStored({
+ accessToken,
+ vault: email,
+ item_type: encoded.itemType,
+ encrypted_data: encoded.encryptedData,
+ location,
+ });
+ }
+
+ async function onSaveEdit(
+ plaintext: VaultLoginPlaintext,
+ location: StorageLocation,
+ ) {
+ if (!accessToken || !vault || !editingItem) return;
+ const encoded = await encryptVaultLogin(plaintext, vault);
+ await updateStored({
+ accessToken,
+ id: editingItem.id,
+ location,
+ item_type: encoded.itemType,
+ encrypted_data: encoded.encryptedData,
+ });
+ setEditingItem(null);
+ await loadItems();
+ showToast("Credential updated");
+ }
+
+ function onExport() {
+ if (!email) return;
+ if (items.length === 0) {
+ showToast("Nothing to export — vault is empty");
+ return;
+ }
+ // We export the underlying ciphertext blobs, NOT the decrypted view.
+ // The backup file is unreadable without the master password.
+ const backup = buildBackup(email, items);
+ downloadBackup(backup);
+ showToast(`Exported ${items.length} encrypted item(s) to a JSON file`);
+ }
+
async function onLogout() {
if (accessToken && refreshToken) {
try {
@@ -262,6 +315,7 @@ export function VaultPage() {
items={items}
scope={scope}
onScopeChange={setScope}
+ onExport={onExport}
/>
@@ -299,9 +353,14 @@ export function VaultPage() {
Decrypted in your browser
-
+
+
+
+
@@ -361,17 +420,32 @@ export function VaultPage() {
markUsed(it.id);
setConnectingItem(it);
}}
+ onEdit={(it) => {
+ setAdding(false);
+ setEditingItem(it);
+ }}
onDelete={onDelete}
onToast={showToast}
/>
)}
- {adding && (
+ {adding && !editingItem && (
setAdding(false)}
/>
)}
+
+ {editingItem && (
+ setEditingItem(null)}
+ />
+ )}
+ {importing && (
+ {
+ setImporting(false);
+ await loadItems();
+ showToast("Import complete");
+ }}
+ />
+ )}
+
{toast && (
diff --git a/packages/web/src/pages/vault/AddCredentialForm.tsx b/packages/web/src/pages/vault/AddCredentialForm.tsx
index 161580b..14832d2 100644
--- a/packages/web/src/pages/vault/AddCredentialForm.tsx
+++ b/packages/web/src/pages/vault/AddCredentialForm.tsx
@@ -4,6 +4,7 @@ import type { Protocol, VaultLoginPlaintext } from "@passman/core";
import { defaultPort, inferProtocolFromPort } from "../../connect/index.js";
import type { StorageLocation } from "../../storage/index.js";
+import { PasswordGenerator } from "./PasswordGenerator.js";
interface DraftState {
name: string;
@@ -18,6 +19,7 @@ interface DraftState {
domain: string;
environment: string;
url: string;
+ privateKey: string;
}
const EMPTY_DRAFT: DraftState = {
@@ -33,6 +35,7 @@ const EMPTY_DRAFT: DraftState = {
domain: "",
environment: "",
url: "",
+ privateKey: "",
};
const PROTOCOL_OPTIONS: { value: Protocol | ""; label: string }[] = [
@@ -51,13 +54,46 @@ const PROTOCOL_OPTIONS: { value: Protocol | ""; label: string }[] = [
];
interface Props {
+ /**
+ * If `initial` is provided, the form switches into Edit mode: the
+ * storage-location toggle is hidden (you can't move items between
+ * stores from here), the heading and submit button change, and the
+ * onSubmit callback receives the original `location` so the parent
+ * routes the update to the right store.
+ */
+ initial?: {
+ plaintext: VaultLoginPlaintext;
+ location: StorageLocation;
+ };
onSubmit: (item: VaultLoginPlaintext, location: StorageLocation) => Promise
;
onCancel: () => void;
}
-export function AddCredentialForm({ onSubmit, onCancel }: Props) {
- const [draft, setDraft] = useState(EMPTY_DRAFT);
+function draftFromPlaintext(p: VaultLoginPlaintext): DraftState {
+ return {
+ name: p.name,
+ username: p.username,
+ password: p.password,
+ hostname: p.hostname ?? "",
+ ip: p.ip ?? "",
+ port: p.port !== undefined ? String(p.port) : "",
+ protocol: p.protocol ?? "",
+ database: p.database ?? "",
+ serviceName: p.serviceName ?? "",
+ domain: p.domain ?? "",
+ environment: p.environment ?? "",
+ url: p.url ?? "",
+ privateKey: p.privateKey ?? "",
+ };
+}
+
+export function AddCredentialForm({ initial, onSubmit, onCancel }: Props) {
+ const isEdit = initial !== undefined;
+ const [draft, setDraft] = useState(
+ initial ? draftFromPlaintext(initial.plaintext) : EMPTY_DRAFT,
+ );
const [storeLocally, setStoreLocally] = useState(false);
+ const [genOpen, setGenOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState(null);
@@ -77,6 +113,8 @@ export function AddCredentialForm({ onSubmit, onCancel }: Props) {
const isOracle = draft.protocol === "oracle";
const isRdp =
draft.protocol === "rdp" || numOrUndef(draft.port) === 3389;
+ const isSsh =
+ draft.protocol === "ssh" || numOrUndef(draft.port) === 22;
async function onFormSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -97,12 +135,29 @@ export function AddCredentialForm({ onSubmit, onCancel }: Props) {
...(draft.domain ? { domain: draft.domain } : {}),
...(draft.environment ? { environment: draft.environment } : {}),
...(draft.url ? { url: draft.url } : {}),
+ ...(draft.privateKey.trim() ? { privateKey: draft.privateKey } : {}),
};
- await onSubmit(cleaned, storeLocally ? "local" : "server");
- setDraft(EMPTY_DRAFT);
- setStoreLocally(false);
+ // In edit mode the storage location is fixed to where the item lives;
+ // moving items between stores is a separate operation we deliberately
+ // don't support from this form.
+ const location = isEdit
+ ? initial!.location
+ : storeLocally
+ ? "local"
+ : "server";
+ await onSubmit(cleaned, location);
+ if (!isEdit) {
+ setDraft(EMPTY_DRAFT);
+ setStoreLocally(false);
+ }
} catch (e) {
- setErr(e instanceof Error ? e.message : "Failed to add credential");
+ setErr(
+ e instanceof Error
+ ? e.message
+ : isEdit
+ ? "Failed to update credential"
+ : "Failed to add credential",
+ );
} finally {
setBusy(false);
}
@@ -110,7 +165,7 @@ export function AddCredentialForm({ onSubmit, onCancel }: Props) {
return (