From f108f497eab5ae87502a526de639b03cb76f0c2c Mon Sep 17 00:00:00 2001 From: noidee Date: Tue, 16 Jun 2026 09:30:30 +0200 Subject: [PATCH 01/24] feat(accounts): add encrypted account export/import module Co-Authored-By: Claude Fable 5 --- src/main/accounts/accountTransfer.test.ts | 60 ++++++++++++ src/main/accounts/accountTransfer.ts | 106 ++++++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 src/main/accounts/accountTransfer.test.ts create mode 100644 src/main/accounts/accountTransfer.ts diff --git a/src/main/accounts/accountTransfer.test.ts b/src/main/accounts/accountTransfer.test.ts new file mode 100644 index 0000000..690303e --- /dev/null +++ b/src/main/accounts/accountTransfer.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; +import { exportAccounts, importAccounts, TransferError, type ExportAccount } from './accountTransfer'; + +const acc: ExportAccount = { + label: 'AWS prod', provider: 'amazon-s3', region: 'eu-central-1', + accessKeyId: 'AK', secretAccessKey: 'SECRET', +}; +const custom: ExportAccount = { + label: 'MinIO', provider: 'custom', region: 'us-east-1', + accessKeyId: 'CK', secretAccessKey: 'CS', endpoint: 'https://minio.example.com', forcePathStyle: true, +}; + +describe('accountTransfer round-trip', () => { + it('exports and imports without a password (unencrypted)', () => { + const blob = exportAccounts([acc]); + expect(importAccounts(blob)).toEqual([acc]); + }); + + it('exports and imports with a password', () => { + const blob = exportAccounts([acc, custom], 'hunter2'); + expect(importAccounts(blob, 'hunter2')).toEqual([acc, custom]); + }); + + it('produces different ciphertext each time (random salt/iv)', () => { + expect(exportAccounts([acc], 'pw')).not.toEqual(exportAccounts([acc], 'pw')); + }); +}); + +describe('accountTransfer errors', () => { + it('throws IncorrectPassword for a wrong password', () => { + const blob = exportAccounts([acc], 'right'); + expect(() => importAccounts(blob, 'wrong')).toThrow(expect.objectContaining({ code: 'IncorrectPassword' })); + }); + + it('throws PasswordRequired when an encrypted blob is imported without a password', () => { + const blob = exportAccounts([acc], 'pw'); + expect(() => importAccounts(blob)).toThrow(expect.objectContaining({ code: 'PasswordRequired' })); + }); + + it('throws InvalidData for non-base64 / non-JSON garbage', () => { + expect(() => importAccounts('!!!not-base64!!!')).toThrow(expect.objectContaining({ code: 'InvalidData' })); + }); + + it('throws InvalidData for a JSON blob that is not our format', () => { + const notOurs = Buffer.from(JSON.stringify({ hello: 'world' }), 'utf8').toString('base64'); + expect(() => importAccounts(notOurs)).toThrow(expect.objectContaining({ code: 'InvalidData' })); + }); + + it('throws IncorrectPassword when the ciphertext is tampered', () => { + const blob = exportAccounts([acc], 'pw'); + const env = JSON.parse(Buffer.from(blob, 'base64').toString('utf8')); + env.data = Buffer.from('tampered-ciphertext').toString('base64'); + const tampered = Buffer.from(JSON.stringify(env), 'utf8').toString('base64'); + expect(() => importAccounts(tampered, 'pw')).toThrow(expect.objectContaining({ code: 'IncorrectPassword' })); + }); + + it('exposes TransferError with a code', () => { + expect(new TransferError('InvalidData', 'x').code).toBe('InvalidData'); + }); +}); diff --git a/src/main/accounts/accountTransfer.ts b/src/main/accounts/accountTransfer.ts new file mode 100644 index 0000000..b34b6d8 --- /dev/null +++ b/src/main/accounts/accountTransfer.ts @@ -0,0 +1,106 @@ +import { randomBytes, scryptSync, createCipheriv, createDecipheriv } from 'node:crypto'; +import type { ProviderId } from '../s3/providers'; + +export interface ExportAccount { + label: string; + provider: ProviderId; + region: string; + accessKeyId: string; + secretAccessKey: string; + endpoint?: string; + forcePathStyle?: boolean; +} + +export type TransferErrorCode = 'PasswordRequired' | 'IncorrectPassword' | 'InvalidData'; + +export class TransferError extends Error { + constructor(public readonly code: TransferErrorCode, message: string) { + super(message); + this.name = 'TransferError'; + } +} + +const FORMAT = 's3manager-accounts'; +const VERSION = 1; +const SCRYPT = { N: 32768, r: 8, p: 1 }; +const KEYLEN = 32; + +function deriveKey(password: string, salt: Buffer): Buffer { + // maxmem must be set explicitly: Node.js defaults to 32 MB, which is exactly + // the lower bound for N=32768, r=8. 64 MB gives comfortable headroom. + return scryptSync(password, salt, KEYLEN, { N: SCRYPT.N, r: SCRYPT.r, p: SCRYPT.p, maxmem: 67108864 }); +} + +export function exportAccounts(accounts: ExportAccount[], password?: string): string { + const payload = JSON.stringify({ accounts }); + let envelope: Record; + if (password && password.length > 0) { + const salt = randomBytes(16); + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', deriveKey(password, salt), iv); + const ciphertext = Buffer.concat([cipher.update(payload, 'utf8'), cipher.final()]); + envelope = { + format: FORMAT, + version: VERSION, + encrypted: true, + kdf: { name: 'scrypt', N: SCRYPT.N, r: SCRYPT.r, p: SCRYPT.p, salt: salt.toString('base64') }, + cipher: 'aes-256-gcm', + iv: iv.toString('base64'), + tag: cipher.getAuthTag().toString('base64'), + data: ciphertext.toString('base64'), + }; + } else { + envelope = { format: FORMAT, version: VERSION, encrypted: false, data: payload }; + } + return Buffer.from(JSON.stringify(envelope), 'utf8').toString('base64'); +} + +export function importAccounts(blob: string, password?: string): ExportAccount[] { + let env: Record; + try { + const json = Buffer.from(blob.trim(), 'base64').toString('utf8'); + const parsed: unknown = JSON.parse(json); + if (typeof parsed !== 'object' || parsed === null) throw new Error('not an object'); + env = parsed as Record; + } catch { + throw new TransferError('InvalidData', 'The import data is not valid.'); + } + if (env.format !== FORMAT || env.version !== VERSION || typeof env.data !== 'string') { + throw new TransferError('InvalidData', 'The import data is not a recognized account export.'); + } + + let payload: string; + if (env.encrypted === true) { + if (!password || password.length === 0) { + throw new TransferError('PasswordRequired', 'This export is password-protected.'); + } + try { + const kdf = env.kdf as { salt: string }; + const salt = Buffer.from(kdf.salt, 'base64'); + const iv = Buffer.from(env.iv as string, 'base64'); + const tag = Buffer.from(env.tag as string, 'base64'); + const decipher = createDecipheriv('aes-256-gcm', deriveKey(password, salt), iv); + decipher.setAuthTag(tag); + payload = Buffer.concat([ + decipher.update(Buffer.from(env.data as string, 'base64')), + decipher.final(), + ]).toString('utf8'); + } catch { + throw new TransferError('IncorrectPassword', 'Incorrect password or corrupted data.'); + } + } else { + payload = env.data; + } + + let parsedPayload: unknown; + try { + parsedPayload = JSON.parse(payload); + } catch { + throw new TransferError('InvalidData', 'The import payload is malformed.'); + } + const accounts = (parsedPayload as { accounts?: unknown }).accounts; + if (!Array.isArray(accounts)) { + throw new TransferError('InvalidData', 'The import payload has no accounts.'); + } + return accounts as ExportAccount[]; +} From 6eeb32a7f6e654552cf7d1ab971d4d4339ff71c5 Mon Sep 17 00:00:00 2001 From: noidee Date: Tue, 16 Jun 2026 09:36:26 +0200 Subject: [PATCH 02/24] feat(ipc): add saveTextFile/openTextFile helpers Co-Authored-By: Claude Fable 5 --- src/main.ts | 20 +++++++++++++++++++- src/main/ipc/channels.ts | 4 ++++ src/main/ipc/register.test.ts | 19 +++++++++++++++++++ src/main/ipc/register.ts | 12 ++++++++++++ src/preload.ts | 2 ++ 5 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/main.ts b/src/main.ts index 300f0e4..518e897 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,6 @@ import { app, BrowserWindow, ipcMain, safeStorage, dialog, shell, nativeTheme } from 'electron'; import path from 'node:path'; +import { readFile, writeFile } from 'node:fs/promises'; import started from 'electron-squirrel-startup'; import { openDatabase } from './main/storage/db'; import { createAccountsRepo } from './main/storage/accountsRepo'; @@ -31,11 +32,28 @@ function initBackend() { : await dialog.showOpenDialog({ properties: ['openDirectory'] }); return result.canceled || !result.filePaths[0] ? null : result.filePaths[0]; }; + const saveTextFile = async (defaultName: string, contents: string): Promise => { + const win = BrowserWindow.getFocusedWindow(); + const result = win + ? await dialog.showSaveDialog(win, { defaultPath: defaultName }) + : await dialog.showSaveDialog({ defaultPath: defaultName }); + if (result.canceled || !result.filePath) return false; + await writeFile(result.filePath, contents, 'utf8'); + return true; + }; + const openTextFile = async (): Promise => { + const win = BrowserWindow.getFocusedWindow(); + const result = win + ? await dialog.showOpenDialog(win, { properties: ['openFile'] }) + : await dialog.showOpenDialog({ properties: ['openFile'] }); + if (result.canceled || !result.filePaths[0]) return null; + return readFile(result.filePaths[0], 'utf8'); + }; const applyTheme = (theme: 'system' | 'light' | 'dark') => { nativeTheme.themeSource = theme; }; applyTheme(readSettings(settings).theme); // honor the persisted choice at launch - registerIpc(ipcMain, { accounts, settings, secrets, crypto: safeStorage, db, saveDialog, selectDirectory, appVersion: app.getVersion(), openExternal: (url) => shell.openExternal(url), applyTheme }); + registerIpc(ipcMain, { accounts, settings, secrets, crypto: safeStorage, db, saveDialog, selectDirectory, saveTextFile, openTextFile, appVersion: app.getVersion(), openExternal: (url) => shell.openExternal(url), applyTheme }); } const createWindow = () => { diff --git a/src/main/ipc/channels.ts b/src/main/ipc/channels.ts index 6d361eb..38bb0c0 100644 --- a/src/main/ipc/channels.ts +++ b/src/main/ipc/channels.ts @@ -54,6 +54,8 @@ export const CH = { setSettings: 'settings:set', getAppInfo: 'app:getInfo', openExternal: 'shell:openExternal', + saveTextFile: 'util:saveTextFile', + openTextFile: 'util:openTextFile', checkForUpdate: 'app:checkForUpdate', getObjectAcl: 's3:getObjectAcl', putObjectAcl: 's3:putObjectAcl', @@ -139,6 +141,8 @@ export interface ApiMap { [CH.setSettings]: { args: [Partial]; res: Result }; [CH.getAppInfo]: { args: []; res: Result }; [CH.openExternal]: { args: [string]; res: Result }; + [CH.saveTextFile]: { args: [{ defaultName: string; contents: string }]; res: Result<{ saved: boolean }> }; + [CH.openTextFile]: { args: []; res: Result }; [CH.checkForUpdate]: { args: []; res: Result }; [CH.getObjectAcl]: { args: [{ accountId: string; bucket: string; key: string }]; res: Result }; [CH.putObjectAcl]: { args: [{ accountId: string; bucket: string; key: string; acl: ObjectAcl }]; res: Result }; diff --git a/src/main/ipc/register.test.ts b/src/main/ipc/register.test.ts index 64aa969..893702c 100644 --- a/src/main/ipc/register.test.ts +++ b/src/main/ipc/register.test.ts @@ -41,6 +41,8 @@ function buildHarness(overrides: Record = {}) { db, saveDialog: vi.fn().mockResolvedValue(null), selectDirectory: vi.fn().mockResolvedValue('/picked/dir'), + saveTextFile: vi.fn().mockResolvedValue(false), + openTextFile: vi.fn().mockResolvedValue(null), appVersion: '1.2.3', openExternal: vi.fn().mockResolvedValue(undefined), ...overrides, @@ -57,6 +59,21 @@ describe('registerIpc', () => { } }); + it('util:saveTextFile delegates to the injected saveTextFile helper', async () => { + const saveTextFile = vi.fn().mockResolvedValue(true); + const { handlers } = buildHarness({ saveTextFile }); + const res = (await handlers.get(CH.saveTextFile)!({ defaultName: 'x.txt', contents: 'hi' })) as { ok: boolean; data: { saved: boolean } }; + expect(saveTextFile).toHaveBeenCalledWith('x.txt', 'hi'); + expect(res).toEqual({ ok: true, data: { saved: true } }); + }); + + it('util:openTextFile delegates to the injected openTextFile helper', async () => { + const openTextFile = vi.fn().mockResolvedValue('file-contents'); + const { handlers } = buildHarness({ openTextFile }); + const res = (await handlers.get(CH.openTextFile)!()) as { ok: boolean; data: string | null }; + expect(res).toEqual({ ok: true, data: 'file-contents' }); + }); + it('shell:openExternal opens http(s) urls', async () => { const { handlers, deps } = buildHarness(); const res = await handlers.get(CH.openExternal)!('https://github.com/facebook/react'); @@ -232,6 +249,8 @@ describe('registerIpc', () => { db, saveDialog: vi.fn().mockResolvedValue(null), selectDirectory: vi.fn().mockResolvedValue('/picked/dir'), + saveTextFile: vi.fn().mockResolvedValue(false), + openTextFile: vi.fn().mockResolvedValue(null), appVersion: '1.2.3', openExternal: vi.fn().mockResolvedValue(undefined), }; diff --git a/src/main/ipc/register.ts b/src/main/ipc/register.ts index 06c5e42..319e760 100644 --- a/src/main/ipc/register.ts +++ b/src/main/ipc/register.ts @@ -53,6 +53,10 @@ export interface RegisterDeps { saveDialog: (defaultFileName: string) => Promise; /** Shows a native folder picker; resolves the chosen directory, or null if cancelled. */ selectDirectory: () => Promise; + /** Saves text to a user-chosen file; resolves true if saved, false if cancelled. Injected by main.ts. */ + saveTextFile: (defaultName: string, contents: string) => Promise; + /** Opens a user-chosen text file and resolves its contents, or null if cancelled. Injected by main.ts. */ + openTextFile: () => Promise; /** The app version string (Electron app.getVersion()), injected by main.ts. */ appVersion: string; /** Opens a URL in the user's default browser (Electron shell.openExternal), injected by main.ts. */ @@ -390,6 +394,14 @@ export function registerIpc(ipcMain: IpcMainLike, deps: RegisterDeps): void { accountCount: deps.accounts.list().length, }), ); + + h(CH.saveTextFile, async (a: { defaultName: string; contents: string }) => { + const saved = await deps.saveTextFile(a.defaultName, a.contents); + return ok({ saved }); + }); + + h(CH.openTextFile, async () => ok(await deps.openTextFile())); + h(CH.checkForUpdate, () => checkForUpdate({ fetchImpl: deps.fetchImpl ?? globalThis.fetch, currentVersion: deps.appVersion }), ); diff --git a/src/preload.ts b/src/preload.ts index d971be8..0fb9777 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -56,6 +56,8 @@ const api = { getEditableMetadata: (a: ApiMap[typeof CH.getEditableMetadata]['args'][0]) => invoke(CH.getEditableMetadata, a), updateObjectMetadata: (a: ApiMap[typeof CH.updateObjectMetadata]['args'][0]) => invoke(CH.updateObjectMetadata, a), openExternal: (url: string) => invoke(CH.openExternal, url), + saveTextFile: (a: ApiMap[typeof CH.saveTextFile]['args'][0]) => invoke(CH.saveTextFile, a), + openTextFile: () => invoke(CH.openTextFile), onSyncProgress: (cb: (p: SyncProgress) => void) => { const listener = (_event: unknown, payload: unknown) => cb(payload as SyncProgress); ipcRenderer.on(SYNC_PROGRESS_CHANNEL, listener); From c03d078797a09c77392abd0ff29e37e5ebfe98b5 Mon Sep 17 00:00:00 2001 From: noidee Date: Tue, 16 Jun 2026 09:39:49 +0200 Subject: [PATCH 03/24] test(ipc): assert openTextFile delegation Co-Authored-By: Claude Fable 5 --- src/main/ipc/register.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/ipc/register.test.ts b/src/main/ipc/register.test.ts index 893702c..de79455 100644 --- a/src/main/ipc/register.test.ts +++ b/src/main/ipc/register.test.ts @@ -71,6 +71,7 @@ describe('registerIpc', () => { const openTextFile = vi.fn().mockResolvedValue('file-contents'); const { handlers } = buildHarness({ openTextFile }); const res = (await handlers.get(CH.openTextFile)!()) as { ok: boolean; data: string | null }; + expect(openTextFile).toHaveBeenCalledTimes(1); expect(res).toEqual({ ok: true, data: 'file-contents' }); }); From 61a4472a068aa4d89c2a7e6892c6515e941243d0 Mon Sep 17 00:00:00 2001 From: noidee Date: Tue, 16 Jun 2026 09:42:36 +0200 Subject: [PATCH 04/24] feat(ipc): add accounts export/import channels Co-Authored-By: Claude Fable 5 --- src/main/ipc/channels.ts | 4 +++ src/main/ipc/register.test.ts | 37 ++++++++++++++++++++++ src/main/ipc/register.ts | 59 +++++++++++++++++++++++++++++++++++ src/preload.ts | 2 ++ 4 files changed, 102 insertions(+) diff --git a/src/main/ipc/channels.ts b/src/main/ipc/channels.ts index 38bb0c0..0809057 100644 --- a/src/main/ipc/channels.ts +++ b/src/main/ipc/channels.ts @@ -19,6 +19,8 @@ export const CH = { accountsUpdate: 'accounts:update', accountsRemove: 'accounts:remove', accountsTest: 'accounts:test', + accountsExport: 'accounts:export', + accountsImport: 'accounts:import', encryptionAvailable: 'secrets:available', listBuckets: 's3:listBuckets', createBucket: 's3:createBucket', @@ -106,6 +108,8 @@ export interface ApiMap { [CH.accountsUpdate]: { args: [UpdateAccountInput]; res: Result }; [CH.accountsRemove]: { args: [string]; res: Result }; [CH.accountsTest]: { args: [TestAccountInput]; res: Result }; + [CH.accountsExport]: { args: [{ accountIds: string[]; password?: string }]; res: Result }; + [CH.accountsImport]: { args: [{ blob: string; password?: string }]; res: Result }; [CH.encryptionAvailable]: { args: []; res: Result }; [CH.listBuckets]: { args: [string]; res: Result }; [CH.createBucket]: { args: [{ accountId: string; bucket: string; objectLock: boolean; versioning: boolean }]; res: Result }; diff --git a/src/main/ipc/register.test.ts b/src/main/ipc/register.test.ts index de79455..430a974 100644 --- a/src/main/ipc/register.test.ts +++ b/src/main/ipc/register.test.ts @@ -227,6 +227,43 @@ describe('registerIpc', () => { void deps; }); + it('accounts:export returns a string that imports back to the account incl. secret', async () => { + const { handlers } = buildHarness(); + const created = (await handlers.get(CH.accountsCreate)!({ + label: 'AWS', provider: 'amazon-s3', region: 'eu-central-1', accessKeyId: 'AK', secretAccessKey: 'SECRET', + })) as { ok: true; data: { id: string } }; + const res = (await handlers.get(CH.accountsExport)!({ accountIds: [created.data.id] })) as { ok: boolean; data: string }; + expect(res.ok).toBe(true); + const { importAccounts } = await import('../accounts/accountTransfer'); + expect(importAccounts(res.data)).toEqual([ + { label: 'AWS', provider: 'amazon-s3', region: 'eu-central-1', accessKeyId: 'AK', secretAccessKey: 'SECRET', endpoint: undefined, forcePathStyle: false }, + ]); + }); + + it('accounts:import creates the accounts and their secrets', async () => { + const { exportAccounts } = await import('../accounts/accountTransfer'); + const blob = exportAccounts([ + { label: 'Imported', provider: 'amazon-s3', region: 'us-east-1', accessKeyId: 'IK', secretAccessKey: 'IS' }, + ]); + const { handlers, deps } = buildHarness(); + const res = (await handlers.get(CH.accountsImport)!({ blob })) as { ok: boolean; data: { id: string }[] }; + expect(res.ok).toBe(true); + expect(res.data).toHaveLength(1); + expect(deps.accounts.list().map((a) => a.label)).toContain('Imported'); + expect(deps.secrets.get(res.data[0].id)).toBe('IS'); + }); + + it('accounts:import rejects an unknown provider without creating anything', async () => { + const { exportAccounts } = await import('../accounts/accountTransfer'); + const blob = exportAccounts([ + { label: 'Bad', provider: 'not-a-provider' as never, region: 'x', accessKeyId: 'K', secretAccessKey: 'S' }, + ]); + const { handlers, deps } = buildHarness(); + const res = (await handlers.get(CH.accountsImport)!({ blob })) as { ok: boolean }; + expect(res.ok).toBe(false); + expect(deps.accounts.list()).toHaveLength(0); + }); + it('accounts:create rejects an unknown provider and persists nothing', async () => { const { handlers, deps } = buildHarness(); const res = (await handlers.get(CH.accountsCreate)!({ diff --git a/src/main/ipc/register.ts b/src/main/ipc/register.ts index 319e760..f1359a2 100644 --- a/src/main/ipc/register.ts +++ b/src/main/ipc/register.ts @@ -27,6 +27,8 @@ import type { ObjectAcl } from '../s3/objectAcl'; import { getEditableMetadata, updateObjectMetadata } from '../s3/objectMetadata'; import { createFolder, moveObject, moveFolder } from '../s3/transfer'; import { createBucket } from '../s3/buckets'; +import { exportAccounts, importAccounts, TransferError } from '../accounts/accountTransfer'; +import type { ExportAccount } from '../accounts/accountTransfer'; import { checkForUpdate } from '../update/checkForUpdate'; import { planSync, runSync, type Endpoint } from '../s3/sync'; import { planLocalSync, runLocalSync } from '../s3/localSync'; @@ -201,6 +203,63 @@ export function registerIpc(ipcMain: IpcMainLike, deps: RegisterDeps): void { return r.ok ? ok(true as const) : err(r.error.code, r.error.message); }); + h(CH.accountsExport, (a: { accountIds: string[]; password?: string }) => { + const accounts: ExportAccount[] = []; + for (const id of a.accountIds) { + const acc = deps.accounts.get(id); + if (!acc) continue; + const secret = deps.secrets.get(id); + if (secret === undefined) { + return err('SecretUnavailable', `Cannot read the secret for account "${acc.label}".`); + } + accounts.push({ + label: acc.label, + provider: acc.provider, + region: acc.region, + accessKeyId: acc.accessKeyId, + secretAccessKey: secret, + endpoint: acc.endpoint, + forcePathStyle: acc.forcePathStyle, + }); + } + if (accounts.length === 0) return err('NothingToExport', 'No accounts to export.'); + return ok(exportAccounts(accounts, a.password)); + }); + + h(CH.accountsImport, (a: { blob: string; password?: string }) => { + let parsed: ExportAccount[]; + try { + parsed = importAccounts(a.blob, a.password); + } catch (e) { + if (e instanceof TransferError) return err(e.code, e.message); + throw e; + } + const resolved: { acc: ExportAccount; params: ConnParams }[] = []; + for (const acc of parsed) { + if (!isKnownProvider(acc.provider)) { + return err('InvalidProvider', `Unknown provider: ${acc.provider}`); + } + const params = resolveConnParams(acc); + if (!params.ok) return params; + resolved.push({ acc, params: params.data }); + } + const created = deps.db.transaction(() => { + return resolved.map(({ acc, params }) => { + const a2 = deps.accounts.create({ + label: acc.label, + provider: acc.provider, + endpoint: params.endpoint, + region: acc.region, + accessKeyId: acc.accessKeyId, + forcePathStyle: params.forcePathStyle, + }); + deps.secrets.set(a2.id, acc.secretAccessKey); + return a2; + }); + })(); + return ok(created); + }); + h(CH.listBuckets, (accountId: string) => listBuckets(clientFor(accountId))); h(CH.createBucket, (a: { accountId: string; bucket: string; objectLock: boolean; versioning: boolean }) => { diff --git a/src/preload.ts b/src/preload.ts index 0fb9777..ace584f 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -15,6 +15,8 @@ const api = { update: (input: ApiMap[typeof CH.accountsUpdate]['args'][0]) => invoke(CH.accountsUpdate, input), remove: (id: string) => invoke(CH.accountsRemove, id), test: (input: ApiMap[typeof CH.accountsTest]['args'][0]) => invoke(CH.accountsTest, input), + export: (a: ApiMap[typeof CH.accountsExport]['args'][0]) => invoke(CH.accountsExport, a), + import: (a: ApiMap[typeof CH.accountsImport]['args'][0]) => invoke(CH.accountsImport, a), }, encryptionAvailable: () => invoke(CH.encryptionAvailable), listBuckets: (accountId: string) => invoke(CH.listBuckets, accountId), From 520f78a2fc37b6cb3303caa6267314eefe6df671 Mon Sep 17 00:00:00 2001 From: noidee Date: Tue, 16 Jun 2026 09:50:32 +0200 Subject: [PATCH 05/24] feat(accounts): add transfer hooks and i18n keys Co-Authored-By: Claude Fable 5 --- .../hooks/useAccountTransfer.test.tsx | 39 +++++++++++++++++++ src/renderer/hooks/useAccountTransfer.ts | 19 +++++++++ src/renderer/i18n/locales/de.json | 23 ++++++++++- src/renderer/i18n/locales/en.json | 23 ++++++++++- src/renderer/i18n/locales/fr.json | 23 ++++++++++- src/renderer/i18n/locales/nl.json | 23 ++++++++++- src/renderer/i18n/locales/pl.json | 23 ++++++++++- src/renderer/i18n/locales/ro.json | 23 ++++++++++- 8 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 src/renderer/hooks/useAccountTransfer.test.tsx create mode 100644 src/renderer/hooks/useAccountTransfer.ts diff --git a/src/renderer/hooks/useAccountTransfer.test.tsx b/src/renderer/hooks/useAccountTransfer.test.tsx new file mode 100644 index 0000000..313bf08 --- /dev/null +++ b/src/renderer/hooks/useAccountTransfer.test.tsx @@ -0,0 +1,39 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import { useExportAccounts, useImportAccounts } from './useAccountTransfer'; + +function wrapper() { + const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +} + +beforeEach(() => { + (window as unknown as { s3: unknown }).s3 = { + accounts: { + export: vi.fn().mockResolvedValue({ ok: true, data: 'BLOB' }), + import: vi.fn().mockResolvedValue({ ok: true, data: [{ id: 'n1' }] }), + }, + }; +}); + +describe('useExportAccounts', () => { + it('returns the export string', async () => { + const { result } = renderHook(() => useExportAccounts(), { wrapper: wrapper() }); + result.current.mutate({ accountIds: ['a'], password: 'pw' }); + await waitFor(() => expect(result.current.data).toBe('BLOB')); + expect((window.s3 as unknown as { accounts: { export: ReturnType } }).accounts.export) + .toHaveBeenCalledWith({ accountIds: ['a'], password: 'pw' }); + }); +}); + +describe('useImportAccounts', () => { + it('returns the imported accounts', async () => { + const { result } = renderHook(() => useImportAccounts(), { wrapper: wrapper() }); + result.current.mutate({ blob: 'BLOB' }); + await waitFor(() => expect(result.current.data).toEqual([{ id: 'n1' }])); + }); +}); diff --git a/src/renderer/hooks/useAccountTransfer.ts b/src/renderer/hooks/useAccountTransfer.ts new file mode 100644 index 0000000..f03108e --- /dev/null +++ b/src/renderer/hooks/useAccountTransfer.ts @@ -0,0 +1,19 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { unwrap } from '../lib/result'; +import { accountsKey } from './useAccounts'; + +export function useExportAccounts() { + return useMutation({ + mutationFn: async (input: { accountIds: string[]; password?: string }) => + unwrap(await window.s3.accounts.export(input)), + }); +} + +export function useImportAccounts() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (input: { blob: string; password?: string }) => + unwrap(await window.s3.accounts.import(input)), + onSuccess: () => qc.invalidateQueries({ queryKey: accountsKey }), + }); +} diff --git a/src/renderer/i18n/locales/de.json b/src/renderer/i18n/locales/de.json index e5dcd9b..8e76694 100644 --- a/src/renderer/i18n/locales/de.json +++ b/src/renderer/i18n/locales/de.json @@ -326,7 +326,28 @@ "moved": "Verschoben", "name": "Name", "noSubfolders": "Keine Unterordner", - "moveHere": "Hierher verschieben" + "moveHere": "Hierher verschieben", + "exportTitle": "Konten exportieren", + "importTitle": "Konten importieren", + "password": "Passwort (optional)", + "importPassword": "Passwort", + "noPasswordWarning": "Ohne Passwort sind die Secret Keys nicht verschlüsselt — halte diesen Export geheim.", + "generate": "Export erzeugen", + "copy": "Kopieren", + "copied": "Kopiert", + "download": "Herunterladen", + "resultAria": "Export-String", + "pastePlaceholder": "Export-String hier einfügen", + "pasteAria": "Importdaten", + "loadFile": "Datei laden", + "import": "Importieren", + "passwordRequired": "Dieser Export ist passwortgeschützt. Bitte Passwort eingeben.", + "incorrectPassword": "Falsches Passwort.", + "invalidData": "Das sieht nicht nach einem gültigen Konten-Export aus.", + "imported": "{{count}} Konten importiert", + "importAccounts": "Importieren", + "exportAll": "Alle exportieren", + "exportAria": "{{label}} exportieren" }, "dashboard": { "title": "Dashboard", diff --git a/src/renderer/i18n/locales/en.json b/src/renderer/i18n/locales/en.json index d772c1b..218952c 100644 --- a/src/renderer/i18n/locales/en.json +++ b/src/renderer/i18n/locales/en.json @@ -326,7 +326,28 @@ "moved": "Moved", "name": "Name", "noSubfolders": "No subfolders", - "moveHere": "Move here" + "moveHere": "Move here", + "exportTitle": "Export accounts", + "importTitle": "Import accounts", + "password": "Password (optional)", + "importPassword": "Password", + "noPasswordWarning": "Without a password the secret keys are not encrypted — keep this export private.", + "generate": "Generate export", + "copy": "Copy", + "copied": "Copied", + "download": "Download", + "resultAria": "Export string", + "pastePlaceholder": "Paste the export string here", + "pasteAria": "Import data", + "loadFile": "Load file", + "import": "Import", + "passwordRequired": "This export is password-protected. Enter the password.", + "incorrectPassword": "Incorrect password.", + "invalidData": "This doesn't look like a valid account export.", + "imported": "Imported {{count}} accounts", + "importAccounts": "Import", + "exportAll": "Export all", + "exportAria": "Export {{label}}" }, "dashboard": { "title": "Dashboard", diff --git a/src/renderer/i18n/locales/fr.json b/src/renderer/i18n/locales/fr.json index c55b92c..c580a34 100644 --- a/src/renderer/i18n/locales/fr.json +++ b/src/renderer/i18n/locales/fr.json @@ -326,7 +326,28 @@ "moved": "Déplacé", "name": "Nom", "noSubfolders": "Aucun sous-dossier", - "moveHere": "Déplacer ici" + "moveHere": "Déplacer ici", + "exportTitle": "Exporter les comptes", + "importTitle": "Importer des comptes", + "password": "Mot de passe (optionnel)", + "importPassword": "Mot de passe", + "noPasswordWarning": "Sans mot de passe, les clés secrètes ne sont pas chiffrées — gardez cet export privé.", + "generate": "Générer l'export", + "copy": "Copier", + "copied": "Copié", + "download": "Télécharger", + "resultAria": "Chaîne d'export", + "pastePlaceholder": "Collez la chaîne d'export ici", + "pasteAria": "Données d'import", + "loadFile": "Charger un fichier", + "import": "Importer", + "passwordRequired": "Cet export est protégé par mot de passe. Saisissez le mot de passe.", + "incorrectPassword": "Mot de passe incorrect.", + "invalidData": "Cela ne ressemble pas à un export de comptes valide.", + "imported": "{{count}} comptes importés", + "importAccounts": "Importer", + "exportAll": "Tout exporter", + "exportAria": "Exporter {{label}}" }, "dashboard": { "title": "Tableau de bord", diff --git a/src/renderer/i18n/locales/nl.json b/src/renderer/i18n/locales/nl.json index 3263cfd..f2d1a95 100644 --- a/src/renderer/i18n/locales/nl.json +++ b/src/renderer/i18n/locales/nl.json @@ -326,7 +326,28 @@ "moved": "Verplaatst", "name": "Naam", "noSubfolders": "Geen submappen", - "moveHere": "Hierheen verplaatsen" + "moveHere": "Hierheen verplaatsen", + "exportTitle": "Accounts exporteren", + "importTitle": "Accounts importeren", + "password": "Wachtwoord (optioneel)", + "importPassword": "Wachtwoord", + "noPasswordWarning": "Zonder wachtwoord zijn de secret keys niet versleuteld — houd deze export privé.", + "generate": "Export genereren", + "copy": "Kopiëren", + "copied": "Gekopieerd", + "download": "Downloaden", + "resultAria": "Exportreeks", + "pastePlaceholder": "Plak hier de exportreeks", + "pasteAria": "Importgegevens", + "loadFile": "Bestand laden", + "import": "Importeren", + "passwordRequired": "Deze export is met een wachtwoord beveiligd. Voer het wachtwoord in.", + "incorrectPassword": "Onjuist wachtwoord.", + "invalidData": "Dit lijkt geen geldige accountexport.", + "imported": "{{count}} accounts geïmporteerd", + "importAccounts": "Importeren", + "exportAll": "Alles exporteren", + "exportAria": "{{label}} exporteren" }, "dashboard": { "title": "Dashboard", diff --git a/src/renderer/i18n/locales/pl.json b/src/renderer/i18n/locales/pl.json index 8ed5f5e..0a91081 100644 --- a/src/renderer/i18n/locales/pl.json +++ b/src/renderer/i18n/locales/pl.json @@ -330,7 +330,28 @@ "moved": "Przeniesiono", "name": "Nazwa", "noSubfolders": "Brak podfolderów", - "moveHere": "Przenieś tutaj" + "moveHere": "Przenieś tutaj", + "exportTitle": "Eksportuj konta", + "importTitle": "Importuj konta", + "password": "Hasło (opcjonalne)", + "importPassword": "Hasło", + "noPasswordWarning": "Bez hasła klucze tajne nie są szyfrowane — zachowaj ten eksport prywatnie.", + "generate": "Wygeneruj eksport", + "copy": "Kopiuj", + "copied": "Skopiowano", + "download": "Pobierz", + "resultAria": "Ciąg eksportu", + "pastePlaceholder": "Wklej tutaj ciąg eksportu", + "pasteAria": "Dane importu", + "loadFile": "Wczytaj plik", + "import": "Importuj", + "passwordRequired": "Ten eksport jest chroniony hasłem. Wprowadź hasło.", + "incorrectPassword": "Nieprawidłowe hasło.", + "invalidData": "To nie wygląda na prawidłowy eksport kont.", + "imported": "Zaimportowano {{count}} kont", + "importAccounts": "Importuj", + "exportAll": "Eksportuj wszystkie", + "exportAria": "Eksportuj {{label}}" }, "dashboard": { "title": "Pulpit", diff --git a/src/renderer/i18n/locales/ro.json b/src/renderer/i18n/locales/ro.json index b0fd945..3ac13f9 100644 --- a/src/renderer/i18n/locales/ro.json +++ b/src/renderer/i18n/locales/ro.json @@ -328,7 +328,28 @@ "moved": "Mutat", "name": "Nume", "noSubfolders": "Niciun subfolder", - "moveHere": "Mută aici" + "moveHere": "Mută aici", + "exportTitle": "Exportă conturile", + "importTitle": "Importă conturi", + "password": "Parolă (opțional)", + "importPassword": "Parolă", + "noPasswordWarning": "Fără parolă, cheile secrete nu sunt criptate — păstrează acest export privat.", + "generate": "Generează exportul", + "copy": "Copiază", + "copied": "Copiat", + "download": "Descarcă", + "resultAria": "Șir de export", + "pastePlaceholder": "Lipește aici șirul de export", + "pasteAria": "Date de import", + "loadFile": "Încarcă fișier", + "import": "Importă", + "passwordRequired": "Acest export este protejat cu parolă. Introdu parola.", + "incorrectPassword": "Parolă incorectă.", + "invalidData": "Acesta nu pare un export de conturi valid.", + "imported": "{{count}} conturi importate", + "importAccounts": "Importă", + "exportAll": "Exportă tot", + "exportAria": "Exportă {{label}}" }, "dashboard": { "title": "Tablou de bord", From f73d38cb0e83ba7d3878850e171f1f2d08957ce3 Mon Sep 17 00:00:00 2001 From: noidee Date: Tue, 16 Jun 2026 09:58:41 +0200 Subject: [PATCH 06/24] feat(accounts): add export dialog Co-Authored-By: Claude Fable 5 --- .../accounts/ExportAccountsDialog.test.tsx | 50 +++++++++++ .../accounts/ExportAccountsDialog.tsx | 86 +++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 src/renderer/components/accounts/ExportAccountsDialog.test.tsx create mode 100644 src/renderer/components/accounts/ExportAccountsDialog.tsx diff --git a/src/renderer/components/accounts/ExportAccountsDialog.test.tsx b/src/renderer/components/accounts/ExportAccountsDialog.test.tsx new file mode 100644 index 0000000..a12ec92 --- /dev/null +++ b/src/renderer/components/accounts/ExportAccountsDialog.test.tsx @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import { ToastProvider } from '../ui/ToastProvider'; +import { ExportAccountsDialog } from './ExportAccountsDialog'; + +function wrap(node: ReactNode) { + const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); + return render( + + {node} + , + ); +} + +beforeEach(() => { + Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } }); + (window as unknown as { s3: unknown }).s3 = { + accounts: { export: vi.fn().mockResolvedValue({ ok: true, data: 'EXPORT-BLOB' }) }, + saveTextFile: vi.fn().mockResolvedValue({ ok: true, data: { saved: true } }), + }; +}); + +describe('ExportAccountsDialog', () => { + it('warns when no password is set and generates the export string', async () => { + wrap( {}} />); + expect(screen.getByText(/secret keys are not encrypted/i)).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Generate export' })); + const out = await screen.findByLabelText('Export string'); + expect(out).toHaveValue('EXPORT-BLOB'); + }); + + it('downloads the generated string via saveTextFile', async () => { + wrap( {}} />); + await userEvent.click(screen.getByRole('button', { name: 'Generate export' })); + await screen.findByLabelText('Export string'); + await userEvent.click(screen.getByRole('button', { name: 'Download' })); + await waitFor(() => + expect(window.s3.saveTextFile).toHaveBeenCalledWith({ defaultName: 's3manager-accounts.txt', contents: 'EXPORT-BLOB' }), + ); + }); + + it('hides the warning once a password is entered', async () => { + wrap( {}} />); + await userEvent.type(screen.getByLabelText('Password (optional)'), 'pw'); + expect(screen.queryByText(/secret keys are not encrypted/i)).not.toBeInTheDocument(); + }); +}); diff --git a/src/renderer/components/accounts/ExportAccountsDialog.tsx b/src/renderer/components/accounts/ExportAccountsDialog.tsx new file mode 100644 index 0000000..09b9517 --- /dev/null +++ b/src/renderer/components/accounts/ExportAccountsDialog.tsx @@ -0,0 +1,86 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FiX } from 'react-icons/fi'; +import { useExportAccounts } from '../../hooks/useAccountTransfer'; +import { useToast } from '../ui/ToastProvider'; + +export function ExportAccountsDialog({ accountIds, onClose }: { accountIds: string[]; onClose: () => void }) { + const { t } = useTranslation(); + const { show } = useToast(); + const exportAccounts = useExportAccounts(); + const [password, setPassword] = useState(''); + const [result, setResult] = useState(null); + + const onGenerate = async () => { + try { + const blob = await exportAccounts.mutateAsync({ accountIds, password: password || undefined }); + setResult(blob); + } catch (e) { + show((e as Error).message, 'error'); + } + }; + + const onCopy = async () => { + if (result === null) return; + await navigator.clipboard.writeText(result); + show(t('transfer.copied')); + }; + + const onDownload = async () => { + if (result === null) return; + await window.s3.saveTextFile({ defaultName: 's3manager-accounts.txt', contents: result }); + }; + + const field = 'mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100'; + + return ( +
+
+
+

{t('transfer.exportTitle')}

+ +
+ + + {password.length === 0 && ( +

{t('transfer.noPasswordWarning')}

+ )} + + {result === null ? ( +
+ +
+ ) : ( + <> +