From 99a0ae3de7898d05495f22f1955e1303b6f46d20 Mon Sep 17 00:00:00 2001 From: Kent Wang Date: Tue, 14 Jan 2025 22:32:27 +0800 Subject: [PATCH 1/4] 1.initial check-in for separate vault key ui changes --- package-lock.json | 14 ++ packages/insomnia/package.json | 1 + packages/insomnia/src/account/session.ts | 2 + packages/insomnia/src/common/settings.ts | 3 + packages/insomnia/src/main.development.ts | 2 + packages/insomnia/src/main/ipc/electron.ts | 7 +- packages/insomnia/src/main/ipc/main.ts | 3 +- .../insomnia/src/main/ipc/secret-storage.ts | 77 ++++++ packages/insomnia/src/main/local-storage.ts | 15 ++ packages/insomnia/src/main/window-utils.ts | 7 +- packages/insomnia/src/models/settings.ts | 2 + packages/insomnia/src/models/user-session.ts | 4 + packages/insomnia/src/preload.ts | 11 + .../src/ui/components/base/copy-button.tsx | 31 ++- .../modals/input-vault-key-modal.tsx | 178 ++++++++++++++ .../src/ui/components/settings/general.tsx | 2 + .../components/settings/vault-key-panel.tsx | 207 ++++++++++++++++ packages/insomnia/src/ui/components/toast.tsx | 42 ++-- .../app/insomnia-event-stream-context.tsx | 23 +- packages/insomnia/src/ui/index.tsx | 20 ++ .../insomnia/src/ui/routes/auth.authorize.tsx | 34 +++ .../insomnia/src/ui/routes/auth.vaultKey.ts | 224 ++++++++++++++++++ packages/insomnia/src/utils/vault.ts | 49 ++++ 23 files changed, 921 insertions(+), 37 deletions(-) create mode 100644 packages/insomnia/src/main/ipc/secret-storage.ts create mode 100644 packages/insomnia/src/ui/components/modals/input-vault-key-modal.tsx create mode 100644 packages/insomnia/src/ui/components/settings/vault-key-panel.tsx create mode 100644 packages/insomnia/src/ui/routes/auth.vaultKey.ts create mode 100644 packages/insomnia/src/utils/vault.ts diff --git a/package-lock.json b/package-lock.json index e2007c93932..0e0a3938eb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1707,6 +1707,19 @@ "node": ">= 20.18.0" } }, + "node_modules/@getinsomnia/srp-js": { + "version": "1.0.0-alpha.1", + "resolved": "https://registry.npmjs.org/@getinsomnia/srp-js/-/srp-js-1.0.0-alpha.1.tgz", + "integrity": "sha512-Svgv1ifeZnGJyofOKXfpLiGYD8zRSGYMUQjZVfckHuFFYmjSSyy17yzYkI0MVcSByG+x5jAlFkJCflOrKn3wQw==", + "dev": true, + "dependencies": { + "@types/node-forge": "^1.3.1", + "blakejs": "^1.2.1", + "jsbn": "^1.1.0", + "node-forge": "^1.3.1", + "tweetnacl": "^1.0.3" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.12.0", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.12.0.tgz", @@ -23134,6 +23147,7 @@ "@fortawesome/free-solid-svg-icons": "^6.5.2", "@fortawesome/react-fontawesome": "^0.2.0", "@getinsomnia/api-client": "0.0.4", + "@getinsomnia/srp-js": "1.0.0-alpha.1", "@sentry/electron": "^5.1.0", "@stoplight/spectral-core": "^1.18.3", "@stoplight/spectral-formats": "^1.6.0", diff --git a/packages/insomnia/package.json b/packages/insomnia/package.json index 2b5c1b95a60..7811ad142a5 100644 --- a/packages/insomnia/package.json +++ b/packages/insomnia/package.json @@ -93,6 +93,7 @@ "@fortawesome/free-solid-svg-icons": "^6.5.2", "@fortawesome/react-fontawesome": "^0.2.0", "@getinsomnia/api-client": "0.0.4", + "@getinsomnia/srp-js": "1.0.0-alpha.1", "@sentry/electron": "^5.1.0", "@stoplight/spectral-core": "^1.18.3", "@stoplight/spectral-formats": "^1.6.0", diff --git a/packages/insomnia/src/account/session.ts b/packages/insomnia/src/account/session.ts index eec1423d900..45b32358a3f 100644 --- a/packages/insomnia/src/account/session.ts +++ b/packages/insomnia/src/account/session.ts @@ -192,6 +192,8 @@ async function _unsetSessionData() { symmetricKey: {} as JsonWebKey, publicKey: {} as JsonWebKey, encPrivateKey: {} as crypt.AESMessage, + vaultSalt: '', + vaultKey: '', }); } diff --git a/packages/insomnia/src/common/settings.ts b/packages/insomnia/src/common/settings.ts index 5ea6537f851..0c251303bd8 100644 --- a/packages/insomnia/src/common/settings.ts +++ b/packages/insomnia/src/common/settings.ts @@ -145,4 +145,7 @@ export interface Settings { useBulkParametersEditor: boolean; validateAuthSSL: boolean; validateSSL: boolean; + // vault related settings + saveVaultKeyLocally: boolean; + enableVaultInScripts: boolean; } diff --git a/packages/insomnia/src/main.development.ts b/packages/insomnia/src/main.development.ts index 6a3d0358a1c..61f72da36a3 100644 --- a/packages/insomnia/src/main.development.ts +++ b/packages/insomnia/src/main.development.ts @@ -15,6 +15,7 @@ import { backupIfNewerVersionAvailable } from './main/backup'; import { ipcMainOn, ipcMainOnce, registerElectronHandlers } from './main/ipc/electron'; import { registergRPCHandlers } from './main/ipc/grpc'; import { registerMainHandlers } from './main/ipc/main'; +import { registerSecretStorageHandlers } from './main/ipc/secret-storage'; import { registerCurlHandlers } from './main/network/curl'; import { registerWebSocketHandlers } from './main/network/websocket'; import { watchProxySettings } from './main/proxy'; @@ -64,6 +65,7 @@ app.on('ready', async () => { registergRPCHandlers(); registerWebSocketHandlers(); registerCurlHandlers(); + registerSecretStorageHandlers(); /** * There's no option that prevents Electron from fetching spellcheck dictionaries from Chromium's CDN and passing a non-resolving URL is the only known way to prevent it from fetching. diff --git a/packages/insomnia/src/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index 206433aa79c..5f01b56f69f 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -28,7 +28,12 @@ export type HandleChannels = | 'webSocket.open' | 'webSocket.readyState' | 'writeFile' - | 'extractJsonFileFromPostmanDataDumpArchive'; + | 'extractJsonFileFromPostmanDataDumpArchive' + | 'secretStorage.setSecret' + | 'secretStorage.getSecret' + | 'secretStorage.deleteSecret' + | 'secretStorage.encryptString' + | 'secretStorage.decryptString'; export const ipcMainHandle = ( channel: HandleChannels, diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index 216a90bd6e3..4f3331e7f49 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -17,7 +17,7 @@ import type { WebSocketBridgeAPI } from '../network/websocket'; import { ipcMainHandle, ipcMainOn, ipcMainOnce, type RendererOnChannels } from './electron'; import extractPostmanDataDumpHandler from './extractPostmanDataDump'; import type { gRPCBridgeAPI } from './grpc'; - +import type { secretStorageBridgeAPI } from './secret-storage'; export interface RendererToMainBridgeAPI { loginStateChange: () => void; openInBrowser: (url: string) => void; @@ -37,6 +37,7 @@ export interface RendererToMainBridgeAPI { webSocket: WebSocketBridgeAPI; grpc: gRPCBridgeAPI; curl: CurlBridgeAPI; + secretStorage: secretStorageBridgeAPI; trackSegmentEvent: (options: { event: string; properties?: Record }) => void; trackPageView: (options: { name: string }) => void; showContextMenu: (options: { key: string; nunjucksTag?: { template: string; range: MarkerRange } }) => void; diff --git a/packages/insomnia/src/main/ipc/secret-storage.ts b/packages/insomnia/src/main/ipc/secret-storage.ts new file mode 100644 index 00000000000..67908aa1eec --- /dev/null +++ b/packages/insomnia/src/main/ipc/secret-storage.ts @@ -0,0 +1,77 @@ +import { safeStorage } from 'electron'; + +import LocalStorage from '../local-storage'; +import { initLocalStorage } from '../window-utils'; +import { ipcMainHandle } from './electron'; + +export interface secretStorageBridgeAPI { + setSecret: typeof setSecret; + getSecret: typeof getSecret; + deleteSecret: typeof deleteSecret; + encryptString: (raw: string) => Promise; + decryptString: (cipherText: string) => Promise; +} + +export function registerSecretStorageHandlers() { + ipcMainHandle('secretStorage.setSecret', (_, key, secret) => setSecret(key, secret)); + ipcMainHandle('secretStorage.getSecret', (_, key) => getSecret(key)); + ipcMainHandle('secretStorage.deleteSecret', (_, key) => deleteSecret(key)); + ipcMainHandle('secretStorage.encryptString', (_, raw) => encryptString(raw)); + ipcMainHandle('secretStorage.decryptString', (_, raw) => decryptString(raw)); +} + +let localStorage: LocalStorage | null = null; + +const getLocalStorage = () => { + if (!localStorage) { + localStorage = initLocalStorage(); + } + return localStorage; +}; + +const setSecret = async (key: string, secret: string) => { + try { + const secretStorage = getLocalStorage(); + const encrypted = encryptString(secret); + secretStorage.setItem(key, encrypted); + } catch (error) { + console.error(`Can not save secret ${error.toString()}`); + return Promise.reject(error); + } +}; + +const getSecret = async (key: string) => { + try { + const secretStorage = getLocalStorage(); + const encrypted = secretStorage.getItem(key, ''); + return encrypted === '' ? null : decryptString(encrypted); + } catch (error) { + console.error(`Can not get secret ${error.toString()}`); + return Promise.reject(null); + } +}; + +const deleteSecret = async (key: string) => { + try { + const secretStorage = getLocalStorage(); + secretStorage.deleteItem(key); + } catch (error) { + console.error(`Can not delele secret ${error.toString()}`); + return Promise.reject(error); + } +}; + +const encryptString = (raw: string) => { + if (safeStorage.isEncryptionAvailable()) { + return safeStorage.encryptString(raw).toString('hex'); + } + return raw; +}; + +const decryptString = (cipherText: string) => { + const buffer = Buffer.from(cipherText, 'hex'); + if (safeStorage.isEncryptionAvailable()) { + return safeStorage.decryptString(buffer); + } + return cipherText; +}; diff --git a/packages/insomnia/src/main/local-storage.ts b/packages/insomnia/src/main/local-storage.ts index 00514a19229..905f3871072 100644 --- a/packages/insomnia/src/main/local-storage.ts +++ b/packages/insomnia/src/main/local-storage.ts @@ -44,6 +44,21 @@ class LocalStorage { } } + deleteItem(key: string) { + clearTimeout(this._timeouts[key]); + delete this._buffer[key]; + + const path = this._getKeyPath(key); + + try { + fs.unlinkSync(path); + } catch (error) { + if (error.code !== 'ENOENT') { + console.error(`[localstorage] Failed to delete item from LocalStorage: ${error}`); + } + } + } + _flush() { const keys = Object.keys(this._buffer); diff --git a/packages/insomnia/src/main/window-utils.ts b/packages/insomnia/src/main/window-utils.ts index 7c87f1bc921..64ce626a99f 100644 --- a/packages/insomnia/src/main/window-utils.ts +++ b/packages/insomnia/src/main/window-utils.ts @@ -50,7 +50,7 @@ interface Bounds { } export function init() { - initLocalStorage(); + localStorage = initLocalStorage(); } const stopAndWaitForHiddenBrowserWindow = async (runningHiddenBrowserWindow: BrowserWindow) => { return await new Promise(resolve => { @@ -800,9 +800,10 @@ export const setZoom = (transformer: (current: number) => number) => () => { localStorage?.setItem('zoomFactor', actual); }; -function initLocalStorage() { +export function initLocalStorage() { const localStoragePath = path.join(process.env['INSOMNIA_DATA_PATH'] || app.getPath('userData'), 'localStorage'); - localStorage = new LocalStorage(localStoragePath); + const localStorage = new LocalStorage(localStoragePath); + return localStorage; } export function createWindowsAndReturnMain({ firstLaunch }: { firstLaunch?: boolean } = {}) { diff --git a/packages/insomnia/src/models/settings.ts b/packages/insomnia/src/models/settings.ts index 68686ad93c0..7e50eab0ae4 100644 --- a/packages/insomnia/src/models/settings.ts +++ b/packages/insomnia/src/models/settings.ts @@ -70,6 +70,8 @@ export function init(): BaseSettings { useBulkParametersEditor: false, validateAuthSSL: true, validateSSL: true, + saveVaultKeyLocally: true, + enableVaultInScripts: false, }; } diff --git a/packages/insomnia/src/models/user-session.ts b/packages/insomnia/src/models/user-session.ts index fed9628ee87..b252f317202 100644 --- a/packages/insomnia/src/models/user-session.ts +++ b/packages/insomnia/src/models/user-session.ts @@ -11,6 +11,8 @@ export interface BaseUserSession { symmetricKey: JsonWebKey; publicKey: JsonWebKey; encPrivateKey: AESMessage; + vaultSalt?: string; + vaultKey?: string; }; export interface HashedUserSession { @@ -34,6 +36,8 @@ export function init(): BaseUserSession { symmetricKey: {} as JsonWebKey, publicKey: {} as JsonWebKey, encPrivateKey: {} as AESMessage, + vaultKey: '', + vaultSalt: '', }; } diff --git a/packages/insomnia/src/preload.ts b/packages/insomnia/src/preload.ts index 38a137b33e3..a81ab11b62f 100644 --- a/packages/insomnia/src/preload.ts +++ b/packages/insomnia/src/preload.ts @@ -1,6 +1,7 @@ import { contextBridge, ipcRenderer, webUtils as _webUtils } from 'electron'; import type { gRPCBridgeAPI } from './main/ipc/grpc'; +import type { secretStorageBridgeAPI } from './main/ipc/secret-storage'; import type { CurlBridgeAPI } from './main/network/curl'; import type { WebSocketBridgeAPI } from './main/network/websocket'; import { invariant } from './utils/invariant'; @@ -40,6 +41,15 @@ const grpc: gRPCBridgeAPI = { loadMethods: options => ipcRenderer.invoke('grpc.loadMethods', options), loadMethodsFromReflection: options => ipcRenderer.invoke('grpc.loadMethodsFromReflection', options), }; + +const secretStorage: secretStorageBridgeAPI = { + setSecret: (key, secret) => ipcRenderer.invoke('secretStorage.setSecret', key, secret), + getSecret: key => ipcRenderer.invoke('secretStorage.getSecret', key), + deleteSecret: key => ipcRenderer.invoke('secretStorage.deleteSecret', key), + encryptString: raw => ipcRenderer.invoke('secretStorage.encryptString', raw), + decryptString: cipherText => ipcRenderer.invoke('secretStorage.decryptString', cipherText), +}; + const main: Window['main'] = { startExecution: options => ipcRenderer.send('startExecution', options), addExecutionStep: options => ipcRenderer.send('addExecutionStep', options), @@ -67,6 +77,7 @@ const main: Window['main'] = { webSocket, grpc, curl, + secretStorage, trackSegmentEvent: options => ipcRenderer.send('trackSegmentEvent', options), trackPageView: options => ipcRenderer.send('trackPageView', options), showContextMenu: options => ipcRenderer.send('show-context-menu', options), diff --git a/packages/insomnia/src/ui/components/base/copy-button.tsx b/packages/insomnia/src/ui/components/base/copy-button.tsx index d5187c9d9bd..51126c0c142 100644 --- a/packages/insomnia/src/ui/components/base/copy-button.tsx +++ b/packages/insomnia/src/ui/components/base/copy-button.tsx @@ -1,21 +1,25 @@ -import React, { type FC, useCallback, useState } from 'react'; +import React, { forwardRef, useCallback, useImperativeHandle, useState } from 'react'; import { useInterval } from 'react-use'; import { Button, type ButtonProps } from '../themed-button'; +export interface CopyBtnHanlde { + copy: () => void; +} interface Props extends ButtonProps { confirmMessage?: string; content: string; title?: string; } -export const CopyButton: FC = ({ - children, - confirmMessage, - content, - title, - ...buttonProps -}) => { +export const CopyButton = forwardRef((props, ref) => { + const { + children, + confirmMessage, + content, + title, + ...buttonProps + } = props; const [showConfirmation, setshowConfirmation] = useState(false); const onClick = useCallback(async (event: React.MouseEvent) => { event.preventDefault(); @@ -31,6 +35,15 @@ export const CopyButton: FC = ({ setshowConfirmation(false); }, 2000); + useImperativeHandle(ref, () => ({ + copy: () => { + if (content) { + window.clipboard.writeText(content); + setshowConfirmation(true); + } + }, + }), [content]); + const confirm = typeof confirmMessage === 'string' ? confirmMessage : 'Copied'; return ( ); -}; +}); diff --git a/packages/insomnia/src/ui/components/modals/input-vault-key-modal.tsx b/packages/insomnia/src/ui/components/modals/input-vault-key-modal.tsx new file mode 100644 index 00000000000..121dc18bb1e --- /dev/null +++ b/packages/insomnia/src/ui/components/modals/input-vault-key-modal.tsx @@ -0,0 +1,178 @@ +import React, { useEffect, useState } from 'react'; +import { Button, Dialog, Heading, Input, Modal, ModalOverlay } from 'react-aria-components'; +import { useFetcher } from 'react-router-dom'; + +import { useRootLoaderData } from '../../routes/root'; +import { Icon } from '../icon'; +import { VaultKeyDisplayInput } from '../settings/vault-key-panel'; +import { showModal } from '.'; +import { AskModal } from './ask-modal'; + +export interface InputVaultKeyModalProps { + onClose: (vaultKey?: string) => void; + allowClose?: boolean; +} + +export const InputVaultKeyModal = (props: InputVaultKeyModalProps) => { + const { onClose, allowClose = true } = props; + const { userSession } = useRootLoaderData(); + const [vaultKey, setVaultKey] = useState(''); + const [error, setError] = useState(''); + const [resetDone, setResetDone] = useState(false); + const resetVaultKeyFetcher = useFetcher(); + const validateVaultKeyFetcher = useFetcher(); + const isLoading = resetVaultKeyFetcher.state !== 'idle' || validateVaultKeyFetcher.state !== 'idle'; + + useEffect(() => { + // close modal and return new vault key after reset + if (resetVaultKeyFetcher.data && !resetVaultKeyFetcher.data.error && resetVaultKeyFetcher.state === 'idle') { + const newVaultKey = resetVaultKeyFetcher.data; + setVaultKey(newVaultKey); + setResetDone(true); + }; + }, [resetVaultKeyFetcher.data, resetVaultKeyFetcher.state]); + + useEffect(() => { + if (resetVaultKeyFetcher?.data?.error && resetVaultKeyFetcher.state === 'idle') { + setError(resetVaultKeyFetcher.data.error); + } + }, [resetVaultKeyFetcher.data, resetVaultKeyFetcher.state]); + + useEffect(() => { + (async () => { + // close modal and return user input vault key if srp validation success + if (validateVaultKeyFetcher.data && !validateVaultKeyFetcher.data.error && validateVaultKeyFetcher.state === 'idle') { + onClose(validateVaultKeyFetcher.data.vaultKey); + }; + })(); + }, [validateVaultKeyFetcher.data, validateVaultKeyFetcher.state, onClose, userSession]); + + useEffect(() => { + if (validateVaultKeyFetcher?.data?.error && validateVaultKeyFetcher.state === 'idle') { + setError(validateVaultKeyFetcher.data.error); + } + }, [validateVaultKeyFetcher.data, validateVaultKeyFetcher.state]); + + const handleValidateVaultKey = () => { + setError(''); + validateVaultKeyFetcher.submit( + { + vaultKey, saveVaultKey: true, + }, + { + action: '/auth/validateVaultKey', + method: 'POST', + encType: 'application/json', + }); + }; + + const resetVaultKey = () => { + showModal(AskModal, { + title: 'Reset Vault Key', + message: 'Are you sure you sure to reset vault key? This will clear all secrets in private environment among all devices.', + yesText: 'Yes', + noText: 'No', + onDone: async (yes: boolean) => { + if (yes) { + // todo clear all local secrets + resetVaultKeyFetcher.submit('', { + action: '/auth/resetVaultKey', + method: 'POST', + }); + } + }, + }); + }; + + return ( + { + !isOpen && onClose(); + }} + className="w-full h-[--visual-viewport-height] fixed z-10 top-0 left-0 flex items-start justify-center bg-black/30" + > + { + !isOpen && onClose(); + }} + > + + {({ close }) => ( +
+
+ + {resetDone ? 'Reset Vault Key' : 'Enter Vault Key'} + + {allowClose && + + } +
+ {!resetDone ? + ( + <> +
+ + setVaultKey(e.target.value)} + /> +
+ {error && +

{error}

+ } +
+
+ Forget Vault Key? + +
+ +
+ + ) : + ( + <> +
Please save or download the vault key which will be needed when you login again.
+ +
+ +
+ + ) + } +
+ )} +
+
+
+ ); +}; diff --git a/packages/insomnia/src/ui/components/settings/general.tsx b/packages/insomnia/src/ui/components/settings/general.tsx index 7a4503209da..4ea3adbd189 100644 --- a/packages/insomnia/src/ui/components/settings/general.tsx +++ b/packages/insomnia/src/ui/components/settings/general.tsx @@ -20,6 +20,7 @@ import { BooleanSetting } from './boolean-setting'; import { EnumSetting } from './enum-setting'; import { NumberSetting } from './number-setting'; import { TextSetting } from './text-setting'; +import { VaultKeyPanel } from './vault-key-panel'; export const General: FC = () => { const { @@ -268,6 +269,7 @@ export const General: FC = () => { help="If checked, validates SSL certificates during authentication flows." /> + {isLoggedIn && } {updatesSupported() && ( diff --git a/packages/insomnia/src/ui/components/settings/vault-key-panel.tsx b/packages/insomnia/src/ui/components/settings/vault-key-panel.tsx new file mode 100644 index 00000000000..c4d74f5fc45 --- /dev/null +++ b/packages/insomnia/src/ui/components/settings/vault-key-panel.tsx @@ -0,0 +1,207 @@ +import fs from 'fs'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Button } from 'react-aria-components'; +import { useFetcher } from 'react-router-dom'; + +import { getProductName } from '../../../common/constants'; +import { decryptVaultKeyFromSession, deleteVaultKeyFromStorage, saveVaultKeyIfNecessary } from '../../../utils/vault'; +import { useRootLoaderData } from '../../routes/root'; +import { type CopyBtnHanlde, CopyButton } from '../base/copy-button'; +import { HelpTooltip } from '../help-tooltip'; +import { Icon } from '../icon'; +import { showError, showModal } from '../modals'; +import { AskModal } from '../modals/ask-modal'; +import { InputVaultKeyModal } from '../modals/input-vault-key-modal'; +import { BooleanSetting } from './boolean-setting'; + +export const VaultKeyDisplayInput = ({ vaultKey }: { vaultKey: string }) => { + const copyBtnRef = useRef(null); + + const donwloadVaultKey = async () => { + const { canceled, filePath: outputPath } = await window.dialog.showSaveDialog({ + title: 'Download Vault Key', + buttonLabel: 'Save', + defaultPath: `${getProductName()}-vault-key-${Date.now()}.txt`, + }); + + if (canceled || !outputPath) { + return; + } + + const to = fs.createWriteStream(outputPath); + + to.on('error', err => { + console.warn('Failed to save vault key', err); + }); + + to.write(vaultKey); + to.end(); + }; + + return ( +
+
copyBtnRef.current?.copy()}>{vaultKey}
+ + + + +
+ + ); +}; + +export const VaultKeyPanel = () => { + const { userSession, settings } = useRootLoaderData(); + const { saveVaultKeyLocally } = settings; + const [isGenerating, setGenerating] = useState(false); + const [vaultKeyValue, setVaultKeyValue] = useState(''); + const [showInputVaultKeyModal, setShowModal] = useState(false); + const { accountId, vaultKey, vaultSalt } = userSession; + const vaultKeyFetcher = useFetcher(); + const vaultSaltFetcher = useFetcher(); + const vaultSaltExists = typeof vaultSalt === 'string' && vaultSalt.length > 0; + const vaultKeyExists = typeof vaultKey === 'string' && vaultKey.length > 0; + + const showVaultKey = useCallback(async () => { + if (vaultKey) { + // decrypt vault key saved in user session + const decryptedVaultKey = await decryptVaultKeyFromSession(vaultKey, false); + setVaultKeyValue(decryptedVaultKey); + } + }, [vaultKey]); + + useEffect(() => { + if (vaultKeyExists) { + showVaultKey(); + } + }, [showVaultKey, vaultKeyExists]); + + useEffect(() => { + if (vaultKeyFetcher.data && !vaultKeyFetcher.data.error && vaultKeyFetcher.state === 'idle') { + setGenerating(false); + setVaultKeyValue(vaultKeyFetcher.data); + }; + }, [vaultKeyFetcher.data, vaultKeyFetcher.state]); + + useEffect(() => { + if (vaultKeyFetcher.data && vaultKeyFetcher.data.error && vaultKeyFetcher.state === 'idle') { + setGenerating(false); + // user has created vault key in another device; + if (vaultKeyFetcher.data.error.toLowerCase().includes('conflict')) { + // get vault salt from server + vaultSaltFetcher.submit('', { + action: '/auth/updateVaultSalt', + method: 'POST', + }); + showModal(AskModal, { + title: 'Vault Key Already Exists', + message: 'You have generated the vault key in other device. Please input your vault key', + yesText: 'OK', + noText: 'Cancel', + }); + } else { + showError({ + title: 'Can not generate vault key', + message: vaultKeyFetcher.data.error, + }); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- vaultSaltFetcher should only be triggered once + }, [vaultKeyFetcher.data, vaultKeyFetcher.state]); + + const generateVaultKey = async () => { + setGenerating(true); + vaultKeyFetcher.submit('', { + action: '/auth/createVaultKey', + method: 'POST', + }); + }; + + const handleModalClose = (newVaultKey?: string) => { + if (newVaultKey) { + setVaultKeyValue(newVaultKey); + }; + setShowModal(false); + }; + + useEffect(() => { + // save or delete vault key to keychain + if (saveVaultKeyLocally) { + if (vaultKeyValue.length > 0) { + saveVaultKeyIfNecessary(accountId, vaultKeyValue); + }; + } else { + deleteVaultKeyFromStorage(accountId); + }; + }, [saveVaultKeyLocally, accountId, vaultKeyValue]); + + return ( +
+ {/* Show Gen Vault button when vault salt does not exist */} + {!vaultSaltExists && +
+ +
+ } + {vaultSaltExists && vaultKeyExists && vaultKeyValue !== '' && + <> +
+
+ Vault Key + The vault key will be needed when you login again. +
+ +
+
+ +
+
+ +
+ + } + {/* User has not input vault key after re-login */} + {vaultSaltExists && !vaultKeyExists && +
+ +
+ } + {showInputVaultKeyModal && + + } +
+ ); +}; diff --git a/packages/insomnia/src/ui/components/toast.tsx b/packages/insomnia/src/ui/components/toast.tsx index e92ee13b39e..02da671f0b6 100644 --- a/packages/insomnia/src/ui/components/toast.tsx +++ b/packages/insomnia/src/ui/components/toast.tsx @@ -19,8 +19,8 @@ const INSOMNIA_NOTIFICATIONS_SEEN = 'insomnia::notifications::seen'; export interface ToastNotification { key: string; - url: string; - cta: string; + url?: string; + cta?: string; message: string; } @@ -132,24 +132,26 @@ export const Toast: FC = () => { Dismiss    - { - if (notification) { - // Hide the currently showing notification - setVisible(false); - // Give time for toast to fade out, then remove it - setTimeout(() => { - setNotification(null); - checkForNotifications(); - }, 1000); - } - }} - href={notification.url} - > - {notification.cta} - + {notification.url && notification.cta && + { + if (notification) { + // Hide the currently showing notification + setVisible(false); + // Give time for toast to fade out, then remove it + setTimeout(() => { + setNotification(null); + checkForNotifications(); + }, 1000); + } + }} + href={notification.url} + > + {notification.cta} + + } diff --git a/packages/insomnia/src/ui/context/app/insomnia-event-stream-context.tsx b/packages/insomnia/src/ui/context/app/insomnia-event-stream-context.tsx index 6c471440f78..65e7077de10 100644 --- a/packages/insomnia/src/ui/context/app/insomnia-event-stream-context.tsx +++ b/packages/insomnia/src/ui/context/app/insomnia-event-stream-context.tsx @@ -4,6 +4,7 @@ import { useFetcher, useParams, useRouteLoaderData } from 'react-router-dom'; import { CDN_INVALIDATION_TTL } from '../../../common/constants'; import { insomniaFetch } from '../../../ui/insomniaFetch'; import { avatarImageCache } from '../../hooks/image-cache'; +import type { OrganizationLoaderData } from '../../routes/organization'; import type { ProjectIdLoaderData } from '../../routes/project'; import { useRootLoaderData } from '../../routes/root'; import type { WorkspaceLoaderData } from '../../routes/workspace'; @@ -52,6 +53,12 @@ interface FileChangedEvent { 'branch': string; } +interface VaultKeyChangeEvent { + type: 'VaultKeyChanged'; + topic: string; + sessionId: string; +}; + export interface UserPresence { acct: string; avatar: string; @@ -81,6 +88,7 @@ export const InsomniaEventStreamProvider: FC = ({ children }) const { userSession } = useRootLoaderData(); const projectData = useRouteLoaderData('/project/:projectId') as ProjectIdLoaderData | null; const workspaceData = useRouteLoaderData(':workspaceId') as WorkspaceLoaderData | null; + const { organizations } = useRouteLoaderData('/organization') as OrganizationLoaderData; const remoteId = projectData?.activeProject?.remoteId || workspaceData?.activeProject.remoteId; const [presence, setPresence] = useState([]); @@ -88,6 +96,7 @@ export const InsomniaEventStreamProvider: FC = ({ children }) const syncStorageRuleFetcher = useFetcher(); const syncProjectsFetcher = useFetcher(); const syncDataFetcher = useFetcher(); + const clearVaultKeyFetcher = useFetcher(); // Update presence when the user switches org, projects, workspaces useEffect(() => { @@ -128,8 +137,7 @@ export const InsomniaEventStreamProvider: FC = ({ children }) source.addEventListener('message', e => { try { - const event = JSON.parse(e.data) as UserPresenceEvent | TeamProjectChangedEvent | FileDeletedEvent | BranchDeletedEvent | FileChangedEvent; - + const event = JSON.parse(e.data) as UserPresenceEvent | TeamProjectChangedEvent | FileDeletedEvent | BranchDeletedEvent | FileChangedEvent | VaultKeyChangeEvent; if (event.type === 'PresentUserLeave') { setPresence(prev => prev.filter(p => { const isSameUser = p.acct === event.acct; @@ -175,6 +183,15 @@ export const InsomniaEventStreamProvider: FC = ({ children }) action: `/organization/${organizationId}/sync-projects`, method: 'POST', }); + } else if (event.type === 'VaultKeyChanged') { + clearVaultKeyFetcher.submit({ + organizations: organizations?.map(org => org.id) || [], + sessionId: event.sessionId, + }, { + action: '/auth/clearVaultKey', + method: 'POST', + encType: 'application/json', + }); } else if (['BranchDeleted', 'FileChanged'].includes(event.type) && event.team === organizationId && remoteId && event.project === remoteId) { syncDataFetcher.submit({}, { method: 'POST', @@ -194,7 +211,7 @@ export const InsomniaEventStreamProvider: FC = ({ children }) } } return; - }, [organizationId, projectId, remoteId, syncDataFetcher, syncOrganizationsFetcher, syncProjectsFetcher, syncStorageRuleFetcher, userSession.id, workspaceId]); + }, [clearVaultKeyFetcher, organizationId, organizations, projectId, remoteId, syncDataFetcher, syncOrganizationsFetcher, syncProjectsFetcher, syncStorageRuleFetcher, userSession.id, workspaceId]); return ( (await import('./routes/auth.authorize')).action(...args), element: , }, + { + path: 'updateVaultSalt', + action: async (...args) => (await import('./routes/auth.vaultKey')).updateVaultSaltAction(...args), + }, + { + path: 'createVaultKey', + action: async (...args) => (await import('./routes/auth.vaultKey')).createVaultKeyAction(...args), + }, + { + path: 'validateVaultKey', + action: async (...args) => (await import('./routes/auth.vaultKey')).validateVaultKeyAction(...args), + }, + { + path: 'resetVaultKey', + action: async (...args) => (await import('./routes/auth.vaultKey')).resetVaultKeyAction(...args), + }, + { + path: 'clearVaultKey', + action: async (...args) => (await import('./routes/auth.vaultKey')).clearVaultKeyAction(...args), + }, ], }, ], diff --git a/packages/insomnia/src/ui/routes/auth.authorize.tsx b/packages/insomnia/src/ui/routes/auth.authorize.tsx index 9898de6cbe7..535119c57c1 100644 --- a/packages/insomnia/src/ui/routes/auth.authorize.tsx +++ b/packages/insomnia/src/ui/routes/auth.authorize.tsx @@ -2,10 +2,14 @@ import React, { Fragment } from 'react'; import { Button, Heading } from 'react-aria-components'; import { type ActionFunction, redirect, useFetcher, useFetchers, useNavigate } from 'react-router-dom'; +import { userSession as sessionModel } from '../../models'; import { invariant } from '../../utils/invariant'; +import { getVaultKeyFromStorage } from '../../utils/vault'; import { SegmentEvent } from '../analytics'; import { getLoginUrl, submitAuthCode } from '../auth-session-provider'; import { Icon } from '../components/icon'; +import { insomniaFetch } from '../insomniaFetch'; +import { validateVaultKey } from './auth.vaultKey'; export const action: ActionFunction = async ({ request, @@ -27,6 +31,36 @@ export const action: ActionFunction = async ({ event: SegmentEvent.loginSuccess, }); window.localStorage.setItem('hasUserLoggedInBefore', 'true'); + const userSession = await sessionModel.getOrCreate(); + const { accountId, id: sessionId } = userSession; + try { + // check vault salt exists in server + const { salt: vaultSalt } = await insomniaFetch<{ + salt?: string; + error?: string; + }>({ + method: 'GET', + path: '/v1/user/vault', + sessionId, + }); + if (vaultSalt) { + // save vault salt to session + await sessionModel.update(userSession, { vaultSalt }); + // get vault key saved in local + const localVaultKey = await getVaultKeyFromStorage(accountId); + if (localVaultKey) { + // validate vault key with server + const validateResult = await validateVaultKey(userSession, localVaultKey, vaultSalt); + if (validateResult) { + // Encrypt vault key and save encrypted vault key & raw vault salt to session + const encryptedVaultKey = await window.main.secretStorage.encryptString(localVaultKey); + await sessionModel.update(userSession, { vaultKey: encryptedVaultKey, vaultSalt }); + }; + } + }; + } catch (err) { + console.error(err); + }; return redirect('/organization'); }; diff --git a/packages/insomnia/src/ui/routes/auth.vaultKey.ts b/packages/insomnia/src/ui/routes/auth.vaultKey.ts new file mode 100644 index 00000000000..171072b70b3 --- /dev/null +++ b/packages/insomnia/src/ui/routes/auth.vaultKey.ts @@ -0,0 +1,224 @@ +import * as srp from '@getinsomnia/srp-js'; +import { ipcRenderer } from 'electron'; +import { type ActionFunction } from 'react-router-dom'; + +import { userSession as sessionModel } from '../../models'; +import type { UserSession } from '../../models/user-session'; +import { base64encode, saveVaultKeyIfNecessary } from '../../utils/vault'; +import type { ToastNotification } from '../components/toast'; +import { insomniaFetch } from '../insomniaFetch'; + +const { + Buffer, + Client, + generateAES256Key, + getRandomHex, + params, + srpGenKey, +} = srp; +interface FetchError { + error?: string; + message?: string; +} + +export const vaultKeyParams = params[2048]; +const vaultKeyRequestBathPath = '/v1/user/vault'; + +const createVaultKeyRequest = async (sessionId: string, salt: string, verifier: string) => + insomniaFetch({ + method: 'POST', + path: vaultKeyRequestBathPath, + data: { salt, verifier }, + sessionId, + }).catch(error => { + console.error(error);; + }); + +const resetVaultKeyRequest = async (sessionId: string, salt: string, verifier: string) => + insomniaFetch({ + method: 'POST', + path: `${vaultKeyRequestBathPath}/reset`, + sessionId, + data: { salt, verifier }, + }).catch(error => { + console.error(error);; + }); + +export const saveVaultKey = async (session: UserSession, vaultKey: string) => { + const { accountId } = session; + // save encrypted vault key and vault salt to session + const encryptedVaultKey = await window.main.secretStorage.encryptString(vaultKey); + await sessionModel.update(session, { vaultKey: encryptedVaultKey }); + + await saveVaultKeyIfNecessary(accountId, vaultKey); +}; + +const createVaultKey = async (type: 'create' | 'reset' = 'create') => { + const userSession = await sessionModel.getOrCreate(); + const { accountId, id: sessionId } = userSession; + + // const vaultKey = await getVaultKeyRequest(sessionId); + const vaultSalt = await getRandomHex(); + const newVaultKey = await generateAES256Key(); + const base64encodedVaultKey = base64encode(JSON.stringify(newVaultKey)); + + try { + // Compute the verifier + const verifier = srp + .computeVerifier( + vaultKeyParams, + Buffer.from(vaultSalt, 'hex'), + Buffer.from(accountId, 'utf8'), + Buffer.from(base64encodedVaultKey, 'base64'), + ) + .toString('hex'); + // send or reset saltAuth & verifier to server + if (type === 'create') { + const response = await createVaultKeyRequest(sessionId, vaultSalt, verifier); + if (response?.error) { + return { error: `${response?.error}: ${response?.message}` }; + }; + } else { + const response = await resetVaultKeyRequest(sessionId, vaultSalt, verifier); + if (response?.error) { + return { error: `${response?.error}: ${response?.message}` }; + }; + }; + + // save encrypted vault key and vault salt to session + await sessionModel.update(userSession, { vaultSalt: vaultSalt }); + await saveVaultKey(userSession, base64encodedVaultKey); + return base64encodedVaultKey; + } catch (error) { + return { error: error.toString() }; + } +}; + +export const validateVaultKey = async (session: UserSession, vaultKey: string, vaultSalt: string) => { + const { id: sessionId, accountId } = session; + const secret1 = await srpGenKey(); + const srpClient = new Client( + vaultKeyParams, + Buffer.from(vaultSalt, 'hex'), + Buffer.from(accountId, 'utf8'), + Buffer.from(vaultKey, 'base64'), + Buffer.from(secret1, 'hex'), + ); + // ~~~~~~~~~~~~~~~~~~~~~ // + // Compute and Submit A // + // ~~~~~~~~~~~~~~~~~~~~~ // + const srpA = srpClient.computeA().toString('hex'); + const { sessionStarterId, srpB, error: verifyAError } = await insomniaFetch<{ + sessionStarterId: string; + srpB: string; + error?: string; + message?: string; + }>({ + method: 'POST', + path: '/v1/user/vault-verify-a', + data: { srpA }, + sessionId, + }); + // ~~~~~~~~~~~~~~~~~~~~~ // + // Compute and Submit M1 // + // ~~~~~~~~~~~~~~~~~~~~~ // + srpClient.setB(new Buffer(srpB, 'hex')); + const srpM1 = srpClient.computeM1().toString('hex'); + const { srpM2, error: verifyM1Error } = await insomniaFetch<{ + srpM2: string; + error?: string; + message?: string; + }>({ + method: 'POST', + path: '/v1/user/vault-verify-m1', + data: { srpM1, sessionStarterId }, + sessionId, + }); + if (verifyAError || verifyM1Error) { + return false; + } + // ~~~~~~~~~~~~~~~~~~~~~~~~~ // + // Verify Server Identity M2 // + // ~~~~~~~~~~~~~~~~~~~~~~~~~ // + srpClient.checkM2(new Buffer(srpM2, 'hex')); + const srpK = srpClient.computeK().toString('hex'); + return srpK; +}; + +export const createVaultKeyAction: ActionFunction = async () => { + return createVaultKey('create'); +}; + +export const resetVaultKeyAction: ActionFunction = async () => { + return createVaultKey('reset'); +}; + +export const updateVaultSaltAction: ActionFunction = async () => { + const userSession = await sessionModel.getOrCreate(); + const { id: sessionId } = userSession; + const { salt: vaultSalt } = await insomniaFetch<{ + salt?: string; + error?: string; + }>({ + method: 'GET', + path: '/v1/user/vault', + sessionId, + }); + if (vaultSalt) { + await sessionModel.update(userSession, { vaultSalt }); + }; + return vaultSalt; +}; + +export const clearVaultKeyAction: ActionFunction = async ({ request }) => { + const { sessionId: resetVaultClientSessionId } = await request.json(); + + const userSession = await sessionModel.getOrCreate(); + const { id: sessionId } = userSession; + const { salt: newVaultSalt } = await insomniaFetch<{ + salt?: string; + error?: string; + }>({ + method: 'GET', + path: '/v1/user/vault', + sessionId, + }).catch(error => { + console.error(`failed to get vault salt ${error.toString()}`); + }) || {}; + // User on other device has reset the vault key. + if (resetVaultClientSessionId !== sessionId) { + // Update vault salt and delelte vault key from session + sessionModel.update(userSession, { vaultSalt: newVaultSalt, vaultKey: '' }); + // show notification + const notification: ToastNotification = { + key: 'Vault key reset', + message: 'Your vault key has been reset, all you local secrets have been deleted.', + }; + ipcRenderer.emit('show-notification', null, notification); + return true; + } + return false; +}; + +export const validateVaultKeyAction: ActionFunction = async ({ request }) => { + const { vaultKey, saveVaultKey: saveVaultKeyLocally = false } = await request.json(); + const userSession = await sessionModel.getOrCreate(); + const { vaultSalt } = userSession; + + if (!vaultSalt) { + return { error: 'Please generate a vault key from preference first' }; + } + + try { + const validateResult = await validateVaultKey(userSession, vaultKey, vaultSalt); + if (!validateResult) { + return { error: 'Invalid vault key, please check and input again' }; + } + if (saveVaultKeyLocally) { + await saveVaultKey(userSession, vaultKey); + }; + return { vaultKey, srpK: validateResult }; + } catch (error) { + return { error: error.toString() }; + }; +}; diff --git a/packages/insomnia/src/utils/vault.ts b/packages/insomnia/src/utils/vault.ts new file mode 100644 index 00000000000..129bc17769c --- /dev/null +++ b/packages/insomnia/src/utils/vault.ts @@ -0,0 +1,49 @@ +import { settings } from '../models'; + +export const base64encode = (input: string | JsonWebKey) => { + const inputStr = typeof input === 'string' ? input : JSON.stringify(input); + return Buffer.from(inputStr, 'utf-8').toString('base64'); +}; + +export const base64decode = (base64Str: string, toObject: boolean) => { + try { + const decodedStr = Buffer.from(base64Str, 'base64').toString('utf-8'); + if (toObject) { + return JSON.parse(decodedStr); + } + return decodedStr; + } catch (error) { + console.error(`failed to base64 decode string ${base64Str}`); + } + return base64Str; +}; + +export const decryptVaultKeyFromSession = async (vaultKey: string, toJsonWebKey: boolean) => { + if (vaultKey) { + const decryptedVaultKey = await window.main.secretStorage.decryptString(vaultKey); + if (toJsonWebKey) { + return base64decode(decryptedVaultKey, true); + }; + return decryptedVaultKey; + } + return ''; +}; + +const getVaultSecretKey = (accountId: string) => `vault_${accountId}`; + +export const saveVaultKeyIfNecessary = async (accountId: string, vaultKey: string) => { + const userSetting = await settings.getOrCreate(); + const { saveVaultKeyLocally } = userSetting; + if (saveVaultKeyLocally) { + await window.main.secretStorage.setSecret(getVaultSecretKey(accountId), vaultKey); + } +}; + +export const getVaultKeyFromStorage = async (accountId: string) => { + const savedVaultKey = await window.main.secretStorage.getSecret(getVaultSecretKey(accountId)); + return savedVaultKey; +}; + +export const deleteVaultKeyFromStorage = async (accountId: string) => { + await window.main.secretStorage.deleteSecret(getVaultSecretKey(accountId)); +}; From d0f5b1729516f1435632c96c405edd924e87b73b Mon Sep 17 00:00:00 2001 From: Kent Wang Date: Wed, 15 Jan 2025 17:00:33 +0800 Subject: [PATCH 2/4] 1.refine localstorage instance --- packages/insomnia/src/main/window-utils.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/insomnia/src/main/window-utils.ts b/packages/insomnia/src/main/window-utils.ts index 64ce626a99f..f0600eb4144 100644 --- a/packages/insomnia/src/main/window-utils.ts +++ b/packages/insomnia/src/main/window-utils.ts @@ -50,7 +50,7 @@ interface Bounds { } export function init() { - localStorage = initLocalStorage(); + initLocalStorage(); } const stopAndWaitForHiddenBrowserWindow = async (runningHiddenBrowserWindow: BrowserWindow) => { return await new Promise(resolve => { @@ -802,7 +802,9 @@ export const setZoom = (transformer: (current: number) => number) => () => { export function initLocalStorage() { const localStoragePath = path.join(process.env['INSOMNIA_DATA_PATH'] || app.getPath('userData'), 'localStorage'); - const localStorage = new LocalStorage(localStoragePath); + if (!localStorage) { + localStorage = new LocalStorage(localStoragePath); + } return localStorage; } From d3a8474db9fcd4d3e1922f170b945ed153a431f2 Mon Sep 17 00:00:00 2001 From: Kent Wang Date: Tue, 21 Jan 2025 17:07:07 +0800 Subject: [PATCH 3/4] 1.use better method for file saving --- .../src/ui/components/settings/vault-key-panel.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/insomnia/src/ui/components/settings/vault-key-panel.tsx b/packages/insomnia/src/ui/components/settings/vault-key-panel.tsx index c4d74f5fc45..e59ae969aba 100644 --- a/packages/insomnia/src/ui/components/settings/vault-key-panel.tsx +++ b/packages/insomnia/src/ui/components/settings/vault-key-panel.tsx @@ -1,4 +1,3 @@ -import fs from 'fs'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Button } from 'react-aria-components'; import { useFetcher } from 'react-router-dom'; @@ -28,14 +27,10 @@ export const VaultKeyDisplayInput = ({ vaultKey }: { vaultKey: string }) => { return; } - const to = fs.createWriteStream(outputPath); - - to.on('error', err => { - console.warn('Failed to save vault key', err); + await window.main.writeFile({ + path: outputPath, + content: vaultKey, }); - - to.write(vaultKey); - to.end(); }; return ( From ebebc0af3624b860da4ed699000573b70cb9663c Mon Sep 17 00:00:00 2001 From: Kent Wang Date: Wed, 22 Jan 2025 13:41:13 +0800 Subject: [PATCH 4/4] 1.remove ref --- .../src/ui/components/base/copy-button.tsx | 35 +++++++------------ .../components/settings/vault-key-panel.tsx | 27 +++++++++++--- 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/packages/insomnia/src/ui/components/base/copy-button.tsx b/packages/insomnia/src/ui/components/base/copy-button.tsx index 51126c0c142..b6aabba6f63 100644 --- a/packages/insomnia/src/ui/components/base/copy-button.tsx +++ b/packages/insomnia/src/ui/components/base/copy-button.tsx @@ -1,25 +1,23 @@ -import React, { forwardRef, useCallback, useImperativeHandle, useState } from 'react'; +import React, { type FC, useCallback, useState } from 'react'; import { useInterval } from 'react-use'; import { Button, type ButtonProps } from '../themed-button'; -export interface CopyBtnHanlde { - copy: () => void; -} interface Props extends ButtonProps { confirmMessage?: string; + showConfirmation?: boolean; content: string; title?: string; } -export const CopyButton = forwardRef((props, ref) => { - const { - children, - confirmMessage, - content, - title, - ...buttonProps - } = props; +export const CopyButton: FC = ({ + children, + confirmMessage, + showConfirmation: showConfirmationProp = false, + content, + title, + ...buttonProps +}) => { const [showConfirmation, setshowConfirmation] = useState(false); const onClick = useCallback(async (event: React.MouseEvent) => { event.preventDefault(); @@ -35,15 +33,6 @@ export const CopyButton = forwardRef((props, ref) => { setshowConfirmation(false); }, 2000); - useImperativeHandle(ref, () => ({ - copy: () => { - if (content) { - window.clipboard.writeText(content); - setshowConfirmation(true); - } - }, - }), [content]); - const confirm = typeof confirmMessage === 'string' ? confirmMessage : 'Copied'; return ( ); -}); +}; diff --git a/packages/insomnia/src/ui/components/settings/vault-key-panel.tsx b/packages/insomnia/src/ui/components/settings/vault-key-panel.tsx index e59ae969aba..4bd66e2910f 100644 --- a/packages/insomnia/src/ui/components/settings/vault-key-panel.tsx +++ b/packages/insomnia/src/ui/components/settings/vault-key-panel.tsx @@ -1,11 +1,12 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Button } from 'react-aria-components'; import { useFetcher } from 'react-router-dom'; +import { useInterval } from 'react-use'; import { getProductName } from '../../../common/constants'; import { decryptVaultKeyFromSession, deleteVaultKeyFromStorage, saveVaultKeyIfNecessary } from '../../../utils/vault'; import { useRootLoaderData } from '../../routes/root'; -import { type CopyBtnHanlde, CopyButton } from '../base/copy-button'; +import { CopyButton } from '../base/copy-button'; import { HelpTooltip } from '../help-tooltip'; import { Icon } from '../icon'; import { showError, showModal } from '../modals'; @@ -14,7 +15,11 @@ import { InputVaultKeyModal } from '../modals/input-vault-key-modal'; import { BooleanSetting } from './boolean-setting'; export const VaultKeyDisplayInput = ({ vaultKey }: { vaultKey: string }) => { - const copyBtnRef = useRef(null); + const [showCopyConfirmation, setShowCopyConfirmation] = useState(false); + + useInterval(() => { + setShowCopyConfirmation(false); + }, 2000); const donwloadVaultKey = async () => { const { canceled, filePath: outputPath } = await window.dialog.showSaveDialog({ @@ -35,12 +40,24 @@ export const VaultKeyDisplayInput = ({ vaultKey }: { vaultKey: string }) => { return (
-
copyBtnRef.current?.copy()}>{vaultKey}
+
{ + event.preventDefault(); + event.stopPropagation(); + if (vaultKey) { + window.clipboard.writeText(vaultKey); + }; + setShowCopyConfirmation(true); + }} + > + {vaultKey} +