Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f108f49
feat(accounts): add encrypted account export/import module
noidee-dev Jun 16, 2026
6eeb32a
feat(ipc): add saveTextFile/openTextFile helpers
noidee-dev Jun 16, 2026
c03d078
test(ipc): assert openTextFile delegation
noidee-dev Jun 16, 2026
61a4472
feat(ipc): add accounts export/import channels
noidee-dev Jun 16, 2026
520f78a
feat(accounts): add transfer hooks and i18n keys
noidee-dev Jun 16, 2026
f73d38c
feat(accounts): add export dialog
noidee-dev Jun 16, 2026
893e722
test(accounts): cover export error toast
noidee-dev Jun 16, 2026
33a244a
feat(accounts): add import dialog
noidee-dev Jun 16, 2026
4f6c347
refactor(ui): extract humanErrorMessage helper for inline errors
noidee-dev Jun 16, 2026
1fb2f41
feat(accounts): export/import entry points in the Accounts screen
noidee-dev Jun 16, 2026
4aca8ab
fix(accounts): localize import error messages
noidee-dev Jun 16, 2026
dc891b0
feat(accounts): add peekEnvelope and ImportPreview type
noidee-dev Jun 16, 2026
b9c1ffb
feat(ipc): add accounts import preview channel
noidee-dev Jun 16, 2026
5f6a76f
fix(accounts): restore string narrowing lost in parseEnvelope extraction
noidee-dev Jun 16, 2026
aea77e2
feat(accounts): add useImportPreview hook
noidee-dev Jun 16, 2026
e20eedb
feat(accounts): hide export password field after generating
noidee-dev Jun 16, 2026
cc148a7
feat(accounts): import preview with conditional password field
noidee-dev Jun 16, 2026
81981af
feat(accounts): import duplicate mode (skip/copy/replace)
noidee-dev Jun 16, 2026
9431f91
refactor(ipc): use top-level Account import in import handler
noidee-dev Jun 16, 2026
e3a5876
feat(ui): add dismissible Modal wrapper (Esc + backdrop)
noidee-dev Jun 16, 2026
12947c6
refactor(ui): adopt Modal in account/bucket/confirm dialogs
noidee-dev Jun 16, 2026
57a27da
refactor(ui): adopt Modal in files/transfer dialogs
noidee-dev Jun 16, 2026
ebe6ef5
feat(accounts): warn and choose skip/copy/replace on name collisions
noidee-dev Jun 16, 2026
c89342d
docs(accounts): add account-transfer design specs and plans
noidee-dev Jun 16, 2026
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
1,195 changes: 1,195 additions & 0 deletions docs/superpowers/plans/2026-06-16-account-transfer.md

Large diffs are not rendered by default.

534 changes: 534 additions & 0 deletions docs/superpowers/plans/2026-06-16-duplicate-and-modal.md

Large diffs are not rendered by default.

743 changes: 743 additions & 0 deletions docs/superpowers/plans/2026-06-16-transfer-dialog-refinements.md

Large diffs are not rendered by default.

165 changes: 165 additions & 0 deletions docs/superpowers/specs/2026-06-16-account-transfer-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# Account import / export — design

## Summary

Let users export one, several, or all S3 accounts to a portable string
(optionally password-encrypted) and import accounts from such a string or a
saved file. Export is reachable per-account and as "export all" from the
Accounts (Connections) screen; import lives there too. The export bundle carries
each account's metadata **and its secret access key**, so the password
encryption is the protective layer.

## Decisions made

- **Scope:** per-account export (a row action) **and** "export all" (header
button); import accepts a pasted string **or** a loaded file.
- **Password optional.** With a password the bundle is encrypted; without one,
the secret keys are only base64-encoded (not encrypted) — the export dialog
shows a clear warning in that case. (User explicitly wanted the password
optional.)
- **Crypto:** scrypt KDF (random salt) → AES-256-GCM (random IV, auth tag). The
GCM tag detects both tampering and a wrong password.
- **Import is all-or-nothing** and always creates **new** account ids (no silent
overwrite; duplicates are possible if the same bundle is imported twice).
- **No new npm dependency** — uses `node:crypto`. File read/write goes through
injected main-process helpers (mirrors the existing `saveDialog`).

## Data shapes

```ts
// One account in the bundle — exactly what accountsCreate needs (no id/createdAt).
interface ExportAccount {
label: string;
provider: ProviderId;
region: string;
accessKeyId: string;
secretAccessKey: string;
endpoint?: string;
forcePathStyle?: boolean;
}

// The decoded envelope (the export string is base64 of JSON.stringify(envelope)).
interface ExportEnvelope {
format: 's3manager-accounts';
version: 1;
encrypted: boolean;
// present only when encrypted:
kdf?: { name: 'scrypt'; N: number; r: number; p: number; salt: string /*base64*/ };
cipher?: 'aes-256-gcm';
iv?: string; // base64
tag?: string; // base64
// encrypted: base64 ciphertext of JSON.stringify({ accounts });
// plaintext: JSON.stringify({ accounts })
data: string;
}
```

scrypt params: `N=32768, r=8, p=1`, keylen 32; salt 16 bytes; GCM iv 12 bytes.

## Components

### Pure module `src/main/accounts/accountTransfer.ts`

No Electron, no fs — only `node:crypto`. Exports:

- `exportAccounts(accounts: ExportAccount[], password?: string): string`
— builds `{ accounts }`, wraps in an `ExportEnvelope` (encrypting when a
non-empty password is given), returns `base64(JSON.stringify(envelope))`.
- `importAccounts(blob: string, password?: string): ExportAccount[]`
— base64-decodes, parses the envelope, validates `format`/`version`; if
`encrypted` and no password → throw `PasswordRequired`; decrypts (GCM failure
→ `IncorrectPassword`); parses and returns `accounts`. Malformed input →
`InvalidData`. Errors are thrown as `Error` with a stable `.code`
(`'PasswordRequired' | 'IncorrectPassword' | 'InvalidData'`) so handlers map
them to `err(code, message)`.

### Main — IPC handlers + file helpers

New `RegisterDeps`:
- `saveTextFile: (defaultName: string, contents: string) => Promise<boolean>`
(true = saved, false = cancelled).
- `openTextFile: () => Promise<string | null>` (file text, or null if cancelled).

`main.ts` wires them with `dialog.showSaveDialog` + `fs.writeFile` and
`dialog.showOpenDialog({ properties: ['openFile'] })` + `fs.readFile`.

New channels:
- `accountsExport: { args: [{ accountIds: string[]; password?: string }]; res: Result<string> }`
— for each id: `accounts.get(id)` + `secrets.get(id)`; skip ids with no
account; if a selected account's secret can't be read → `err`. Build
`ExportAccount[]`, call `exportAccounts`, return the string.
- `accountsImport: { args: [{ blob: string; password?: string }]; res: Result<Account[]> }`
— `importAccounts(blob, password)`; validate every provider is known and
resolve conn params (reuse the existing create-path validation); then in **one
`db.transaction`** create each account + `secrets.set`. Return the created
accounts. Any validation failure → `err` before writing anything.
- `saveTextFile: { args: [{ defaultName: string; contents: string }]; res: Result<{ saved: boolean }> }`
- `openTextFile: { args: []; res: Result<string | null> }`

`preload.ts` exposes all four.

### Renderer

- Hooks `src/renderer/hooks/useAccountTransfer.ts`: `useExportAccounts()` and
`useImportAccounts()` mutations (the latter invalidates the accounts query on
success). Plus thin wrappers for `saveTextFile` / `openTextFile` used by the
dialogs.
- `ExportAccountsDialog({ accountIds, onClose })` — optional password field;
"Generate" → `accountsExport` → shows the result in a readonly textarea with
**Copy** and **Download** (`saveTextFile`, default name
`s3manager-accounts.txt`). Shows the no-password warning when the field is
empty.
- `ImportAccountsDialog({ onClose, onImported })` — paste textarea + **Load
file** (`openTextFile` fills the textarea) + optional password field;
"Import" → `accountsImport`. On `PasswordRequired`/`IncorrectPassword`, show an
inline message asking for (or correcting) the password and keep the dialog
open. On success: toast "N imported", call `onImported`, close.

### `ConnectionsScreen` entry points

- Header (list view): existing "Add account" plus **Import** and **Export all**
(the latter disabled when there are zero accounts).
- Each account row: a new **Export** icon button alongside edit/remove, opening
`ExportAccountsDialog` with that single id.

## Error handling & edge cases

- Encryption unavailable (`secrets.get` can't decrypt) → export `err` with a
clear message.
- Import of an unknown provider or invalid endpoint → `err`, nothing created.
- Wrong/missing password → mapped error surfaced inline in the import dialog.
- Empty selection / empty paste → button disabled.
- Tampered ciphertext → GCM failure → `IncorrectPassword` (we don't distinguish
tamper from wrong key; both mean "can't decrypt").

## i18n

New keys in all six locales for both dialogs and the entry-point buttons
(generate, copy, download, password, no-password warning, paste, load file,
import, "N imported", error messages, export/import/export-all labels and aria).

## Testing (TDD)

- `accountTransfer.test.ts`: round-trip with and without password; wrong
password → `IncorrectPassword`; encrypted blob + no password →
`PasswordRequired`; non-base64 / wrong-format / wrong-version → `InvalidData`;
tampered ciphertext → `IncorrectPassword`; multi-account round-trip.
- `register.test.ts`: `accountsExport` returns a string that decodes to the
selected account incl. its secret (stub secrets); `accountsImport` creates the
accounts + secrets (assert repo + secrets state) and is all-or-nothing on a
bad provider; `saveTextFile`/`openTextFile` delegate to the injected helpers.
- `ExportAccountsDialog.test.tsx`: generate shows the string; download calls
`saveTextFile`; no-password warning visible/hidden.
- `ImportAccountsDialog.test.tsx`: paste + import calls `accountsImport`; load
file fills the textarea; incorrect-password path shows the inline error.
- `ConnectionsScreen.test.tsx`: row Export opens the dialog; header Import /
Export all open their dialogs; Export all disabled with zero accounts.

## Out of scope

- Cloud sync of accounts; QR codes; key rotation; merging/dedup on import;
exporting bucket/sync configuration. Only accounts + their secrets.

## Open questions

None.
135 changes: 135 additions & 0 deletions docs/superpowers/specs/2026-06-16-duplicate-and-modal-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# Duplicate-name import handling & dismissible modals — design

## Summary

Two independent refinements on the `feat/account-import-export` branch:

1. **Duplicate names on import:** when an imported account's name (label)
already exists, warn the user and let them choose, for all collisions at
once: **skip**, **import as copies**, or **replace** the existing account.
2. **Dismissible modals:** every modal in the app closes via **Esc** and via a
**backdrop click** (clicking outside the panel), through one shared `Modal`
wrapper that all 10 dialogs adopt.

## Decisions made

- **Duplicate options:** one global choice — `skip | copy | replace` — applied
to every name collision. `replace` matches by label and updates the existing
account (credentials included). Default selection is **skip**.
- **Backward compatible:** the import IPC's new `onDuplicate` argument defaults
to `'copy'` (today's behaviour), so callers that omit it are unchanged.
- **Modal scope:** all 10 dialogs adopt a shared `ui/Modal` (Export, Import,
QuickAdd, CreateBucket, Metadata, Permissions, UploadLink, Move, Name,
Confirm). Also resolves the ROADMAP "Escape-to-close" a11y item.
- **Both parts land on `feat/account-import-export`.**

## Part A — duplicate-name handling

### Backend — `accountsImport` gains `onDuplicate`

- `ApiMap[CH.accountsImport].args` becomes
`[{ blob: string; password?: string; onDuplicate?: 'skip' | 'copy' | 'replace' }]`.
- Handler: parse + validate every provider as today (all-or-nothing). Snapshot
`existing = deps.accounts.list()` once. In the single `db.transaction`, for
each resolved account, find `dup = existing.find(e => e.label === acc.label)`:
- `onDuplicate === 'skip'` and `dup` → skip it.
- `onDuplicate === 'replace'` and `dup` → `accounts.update(dup.id, {...fields})`
+ `secrets.set(dup.id, secret)`; push the updated account.
- otherwise (`'copy'`, the default, or no `dup`) → `accounts.create` +
`secrets.set`; push the created account.
- Return the array of created/updated accounts.
- Edge: if several existing accounts share a label, `replace` updates the
first match (snapshot order). Documented, acceptable.
- `useImportAccounts` mutation input type widens to include the optional
`onDuplicate`. `preload` already forwards the whole arg object — no change.

### Renderer — `ImportAccountsDialog`

- Read existing accounts via `useAccounts`. From the **preview** list compute
collisions: `preview.accounts.filter(a => existingLabels.has(a.label))`.
- When `collisions.length > 0`:
- Show a warning line `transfer.duplicateWarning` ({{count}}).
- Show a labelled `<select>` (`transfer.duplicateMode`) with options
`transfer.duplicateSkip` / `transfer.duplicateCopy` /
`transfer.duplicateReplace`; state `duplicateMode` defaults to `'skip'`.
- Mark each colliding row in the preview list with `transfer.nameExists`.
- Import passes `onDuplicate: collisions.length > 0 ? duplicateMode : 'copy'`.
- The import still goes through `useImportAccounts` (which invalidates the
accounts query), so replaced/created accounts refresh the list.

### i18n (6 locales)

`transfer.duplicateWarning` ("{{count}} names already exist"),
`transfer.duplicateMode` ("Existing names"), `transfer.duplicateSkip` ("Skip"),
`transfer.duplicateCopy` ("Import as copies"), `transfer.duplicateReplace`
("Replace existing"), `transfer.nameExists` ("name exists").

## Part B — shared dismissible `Modal`

### `src/renderer/components/ui/Modal.tsx`

```ts
function Modal({ onDismiss, className, children }: {
onDismiss: () => void;
className?: string; // panel classes (width/padding/bg)
children: ReactNode;
}): JSX.Element
```

- Renders the overlay `<div className="fixed inset-0 z-10 flex items-center justify-center bg-black/30" role="dialog" aria-modal="true">` with a panel child carrying `className`.
- **Esc:** a `document` keydown listener (added/removed in an effect) calls
`onDismiss` on `Escape`.
- **Backdrop:** the overlay's `onMouseDown` calls `onDismiss` only when
`e.target === e.currentTarget` (the overlay itself, not a child) — so clicks
inside the panel never dismiss; no `stopPropagation` needed.

### Refactor the 10 dialogs

Each dialog's hand-rolled
`<div className="fixed inset-0 … bg-black/30" role="dialog" aria-modal="true"><div className="<panel>">…</div></div>`
becomes `<Modal onDismiss={<onClose|onCancel>} className="<panel>">…</Modal>`,
dropping the duplicated overlay/role/aria. `ConfirmDialog` uses `onCancel` as
`onDismiss`. Panel classes (e.g. `w-80`, `w-96`, `w-[28rem]`) move to the
`className` prop unchanged. Inner content (headers, FiX buttons, forms) is
untouched.

Dialogs: `ExportAccountsDialog`, `ImportAccountsDialog`, `QuickAddAccountDialog`,
`CreateBucketDialog`, `MetadataDialog`, `PermissionsDialog`, `UploadLinkDialog`,
`MoveDialog`, `NameDialog`, `ConfirmDialog`.

## Error handling & edge cases

- Empty existing list → no collisions → chooser hidden → import `'copy'`.
- Live DB differs from the preview snapshot (account added meanwhile): the
handler re-detects collisions against the live list and is authoritative.
- Backdrop/Esc dismiss while a dialog has unsaved input or a pending action: the
user explicitly asked for this; dismiss = the dialog's existing cancel/close.
- Existing dialog FiX/Cancel buttons keep working (they call the same handler).

## Testing (TDD)

- `register.test.ts`: `accountsImport` with `onDuplicate` —
`skip` (collision skipped, non-colliding created), `copy`/default (duplicate
created), `replace` (existing account updated in place + secret replaced, no
new row). Assert via `deps.accounts.list()` / `deps.secrets.get`.
- `Modal.test.tsx`: Esc fires `onDismiss`; backdrop click fires `onDismiss`;
a click inside the panel does NOT; renders `role="dialog"` with the panel
className.
- Each refactored dialog's existing test still passes (content + role + buttons
unchanged). Add an Esc-closes test to `ConfirmDialog.test.tsx` as a
representative.
- `ImportAccountsDialog.test.tsx`: a preview with a label that matches an
existing account shows the warning + chooser and marks the row; importing with
the chooser set to `replace` calls `accounts.import` with
`onDuplicate: 'replace'`; no collision → no warning, import sends `'copy'`.

## Out of scope

- Per-account duplicate decisions (one global choice only).
- `aria-labelledby` wiring for dialog titles (separate a11y task).
- Focus-trapping inside modals (Esc + backdrop only).
- Deduplicating accounts already in the database.

## Open questions

None.
Loading