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 (
-

New credential

+

{isEdit ? `Edit ${initial!.plaintext.name}` : "New credential"}

-