diff --git a/source/main/services/init.ts b/source/main/services/init.ts index a6df9cca..3853b4cb 100644 --- a/source/main/services/init.ts +++ b/source/main/services/init.ts @@ -6,7 +6,7 @@ import { applyCurrentTheme } from "./theme"; import { updateTrayIcon } from "../actions/tray"; import { updateAppMenu } from "../actions/appMenu"; import { getConfigValue, initialise as initialiseConfig } from "./config"; -import { getConfigPath, getVaultStoragePath } from "./storage"; +import { getConfigPath, getVaultSettingsPath, getVaultStoragePath } from "./storage"; import { getOSLocale } from "./locale"; import { startFileHost } from "./fileHost"; import { isPortable } from "../library/portability"; @@ -28,6 +28,7 @@ export async function initialise() { logInfo(`Logs location: ${getLogPath()}`); logInfo(`Config location: ${getConfigPath()}`); logInfo(`Vault config storage location: ${getVaultStoragePath()}`); + logInfo(`Vault-specific settings path: ${getVaultSettingsPath("")}`); await initialiseConfig(); const preferences = await getConfigValue("preferences"); const locale = await getOSLocale(); diff --git a/source/renderer/actions/facade.ts b/source/renderer/actions/facade.ts index c993f691..ca218622 100644 --- a/source/renderer/actions/facade.ts +++ b/source/renderer/actions/facade.ts @@ -1,6 +1,6 @@ import { ipcRenderer } from "electron"; import { VaultFacade, VaultSourceID } from "buttercup"; -import { setCurrentVault, setCurrentVaultSupportsAttachments } from "../state/vaults"; +import { VAULTS_STATE } from "../state/vaults"; import { setCurrentFacade } from "../services/facade"; export async function fetchUpdatedFacade(sourceID: VaultSourceID) { @@ -9,8 +9,8 @@ export async function fetchUpdatedFacade(sourceID: VaultSourceID) { sourceID ); const facade: VaultFacade = JSON.parse(rawFacade); - setCurrentVaultSupportsAttachments(attachments); - setCurrentVault(sourceID); + VAULTS_STATE.currentVaultAttachments = !!attachments; + VAULTS_STATE.currentVault = sourceID; if (!facade) { setCurrentFacade(null); return; diff --git a/source/renderer/actions/password.ts b/source/renderer/actions/password.ts index 73242a9e..e1267b6b 100644 --- a/source/renderer/actions/password.ts +++ b/source/renderer/actions/password.ts @@ -1,24 +1,28 @@ import { VaultSourceID } from "buttercup"; import { getPasswordEmitter } from "../services/password"; import { sourceHasBiometricAvailability } from "../services/biometrics"; -import { setBiometricSourceID, showPasswordPrompt } from "../state/password"; +import { PASSWORD_STATE } from "../state/password"; -export async function getPrimaryPassword(sourceID?: VaultSourceID): Promise { +export async function getPrimaryPassword( + sourceID?: VaultSourceID +): Promise<[password: string | null, biometricsEnabled: boolean, usedBiometrics: boolean]> { + let biometricsEnabled: boolean = false; if (sourceID) { const supportsBiometrics = await sourceHasBiometricAvailability(sourceID); if (supportsBiometrics) { - setBiometricSourceID(sourceID); + PASSWORD_STATE.passwordViaBiometricSource = sourceID; + biometricsEnabled = true; } } - showPasswordPrompt(true); + PASSWORD_STATE.showPrompt = true; const emitter = getPasswordEmitter(); - const password = await new Promise((resolve) => { - const callback = (password: string | null) => { - resolve(password); + const [password, usedBiometrics] = await new Promise<[string | null, boolean]>((resolve) => { + const callback = (password: string | null, usedBiometrics: boolean) => { + resolve([password, usedBiometrics]); emitter.removeListener("password", callback); }; emitter.once("password", callback); }); - setBiometricSourceID(null); - return password; + PASSWORD_STATE.passwordViaBiometricSource = null; + return [password, biometricsEnabled, usedBiometrics]; } diff --git a/source/renderer/actions/removeVault.ts b/source/renderer/actions/removeVault.ts index 72a090aa..737c17e1 100644 --- a/source/renderer/actions/removeVault.ts +++ b/source/renderer/actions/removeVault.ts @@ -1,10 +1,10 @@ import { ipcRenderer } from "electron"; import { VaultSourceID } from "buttercup"; -import { getCurrentSourceID, setCurrentVault } from "../state/vaults"; +import { VAULTS_STATE } from "../state/vaults"; export async function removeVaultSource(sourceID: VaultSourceID) { - if (sourceID === getCurrentSourceID()) { - setCurrentVault(null); + if (sourceID === VAULTS_STATE.currentVault) { + VAULTS_STATE.currentVault = null; } ipcRenderer.send( "remove-source", diff --git a/source/renderer/actions/unlockVault.ts b/source/renderer/actions/unlockVault.ts index 207fa1df..5d77f02b 100644 --- a/source/renderer/actions/unlockVault.ts +++ b/source/renderer/actions/unlockVault.ts @@ -4,10 +4,11 @@ import { getPrimaryPassword } from "./password"; import { setBusy } from "../state/app"; import { showError } from "../services/notifications"; import { logInfo } from "../library/log"; +import { getVaultSettings, saveVaultSettings } from "../services/vaultSettings"; import { t } from "../../shared/i18n/trans"; export async function unlockVaultSource(sourceID: VaultSourceID): Promise { - const password = await getPrimaryPassword(sourceID); + const [password, biometricsEnabled, usedBiometrics] = await getPrimaryPassword(sourceID); if (!password) return false; setBusy(true); logInfo(`Unlocking source: ${sourceID}`); @@ -23,6 +24,47 @@ export async function unlockVaultSource(sourceID: VaultSourceID): Promise 0 && usedBiometrics) { + // Max password count enabled, increment count + vaultSettings.biometricUnlockCount += 1; + logInfo(`biometric unlock count increased: ${vaultSettings.biometricUnlockCount}`); + } else { + // Not enabled, ensure 0 + vaultSettings.biometricUnlockCount = 0; + } + if (!isNaN(maxInterval) && maxInterval > 0 && usedBiometrics) { + // Interval enabled, set to now + if ( + typeof vaultSettings.biometricLastManualUnlock === "number" && + vaultSettings.biometricLastManualUnlock > 0 + ) { + logInfo( + `biometric unlock date ignored as already set: ${vaultSettings.biometricLastManualUnlock}` + ); + } else { + vaultSettings.biometricLastManualUnlock = Date.now(); + logInfo(`biometric unlock date set: ${vaultSettings.biometricLastManualUnlock}`); + } + } else if ( + typeof vaultSettings.biometricLastManualUnlock === "number" && + vaultSettings.biometricLastManualUnlock > 0 + ) { + // Exceeded: new date + vaultSettings.biometricLastManualUnlock = Date.now(); + logInfo(`biometric unlock date reset: ${vaultSettings.biometricLastManualUnlock}`); + } else { + // Not enabled: back to null + vaultSettings.biometricLastManualUnlock = null; + } + await saveVaultSettings(sourceID, vaultSettings); + } + // Return result logInfo(`Unlocked source: ${sourceID}`); return true; } diff --git a/source/renderer/components/PasswordPrompt.tsx b/source/renderer/components/PasswordPrompt.tsx index 8f771da4..8f03f5e1 100644 --- a/source/renderer/components/PasswordPrompt.tsx +++ b/source/renderer/components/PasswordPrompt.tsx @@ -1,173 +1,190 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { useState as useHookState } from "@hookstate/core"; -import { - Button, - Classes, - Dialog, - FormGroup, - InputGroup, - Intent, - NonIdealState -} from "@blueprintjs/core"; +import { useSingleState } from "react-obstate"; +import { Button, Classes, Colors, Dialog, FormGroup, InputGroup, Intent, NonIdealState } from "@blueprintjs/core"; import { Layerr } from "layerr"; +import ms from "ms"; +import styled from "styled-components"; +import { PASSWORD_STATE } from "../state/password"; +import { VAULTS_STATE } from "../state/vaults"; import { getPasswordEmitter } from "../services/password"; import { getBiometricSourcePassword } from "../services/biometrics"; -import { PASSWORD_VIA_BIOMETRIC_SOURCE, SHOW_PROMPT } from "../state/password"; +import { getVaultSettings, saveVaultSettings } from "../services/vaultSettings"; import { showError } from "../services/notifications"; +import { logErr, logInfo } from "../library/log"; import { t } from "../../shared/i18n/trans"; -import { logErr } from "../library/log"; +import { VAULT_SETTINGS_DEFAULT } from "../../shared/symbols"; import { VaultSettingsLocal } from "../../shared/types"; -import { naiveClone } from "../../shared/library/clone"; -import { getVaultSettings, saveVaultSettings } from "../services/vaultSettings"; -const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000; +enum PromptType { + Biometric = "biometric", + None = "none", + Password = "password" +} + +const DAY_MS = ms("1d"); + +const FallbackText = styled.i` + color: ${Colors.RED3}; + font-weight: 500; + display: block; + margin-bottom: 12px; +`; export function PasswordPrompt() { const emitter = useMemo(getPasswordEmitter, []); - const showPromptState = useHookState(SHOW_PROMPT); - const biometricSourceState = useHookState(PASSWORD_VIA_BIOMETRIC_SOURCE); - const [promptedBiometrics, setPromptedBiometrics] = useState(false); - const [biometricsPromptActive, setBiometricsPromptActive] = useState(false); + const [biometricSourceID] = useSingleState(PASSWORD_STATE, "passwordViaBiometricSource"); + const [showPrompt, setShowPrompt] = useSingleState(PASSWORD_STATE, "showPrompt"); const [currentPassword, setCurrentPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); - const [settings, _setSettings] = useState(null); - const saveAndReloadSettings = (sourceID, settings) => { - saveVaultSettings(sourceID, settings) - .then(() => { - _setSettings(settings); - }) - .catch((err) => { - logErr("Failed saving vault settings", err); - }); - } - const close = useCallback(() => { + const [sourceID] = useSingleState(VAULTS_STATE, "currentVault"); + const [settings, setSettings] = useState(null); + const [promptedBiometrics, setPromptedBiometrics] = useState(false); + // Callbacks + const closePrompt = useCallback(() => { setCurrentPassword(""); // clear - showPromptState.set(false); + setShowPrompt(false); + setPromptedBiometrics(false); emitter.emit("password", null); + }, [emitter, setShowPrompt]); + const submitPasswordPrompt = useCallback((password: string, usedBiometrics: boolean) => { + emitter.emit("password", password, usedBiometrics); + setShowPrompt(false); + setCurrentPassword(""); setPromptedBiometrics(false); - }, [emitter]); - const submitAndClose = useCallback( - (password, isBioUnlock = false) => { - setCurrentPassword(""); // clear - showPromptState.set(false); - emitter.emit("password", password); - setPromptedBiometrics(false); - const sourceID = biometricSourceState.get(); - if (isBioUnlock) { - const biometricUnlockCount = settings.biometricUnlockCount + 1; - const _settings = { - ...naiveClone(settings), - biometricUnlockCount - }; - saveAndReloadSettings(sourceID, _settings); - } else { - if (sourceID) { - const _settings = { - ...naiveClone(settings), - biometricUnlockCount: 0, - biometricLastManualUnlock: Date.now() - }; - saveAndReloadSettings(sourceID, _settings); - } - } - }, - [emitter, settings] - ); + }, [emitter, setShowPrompt]); const handleKeyPress = useCallback( (event) => { if (event.key === "Enter") { - submitAndClose(currentPassword); + submitPasswordPrompt(currentPassword, false); } }, - [currentPassword] + [currentPassword, submitPasswordPrompt] ); - useEffect(() => { - const sourceID = biometricSourceState.get(); - if (!sourceID) return; - getVaultSettings(sourceID) - .then((settings) => { - _setSettings(naiveClone(settings)); + // Living data + const [promptType, fallbackReason] = useMemo<[PromptType, string | null]>(() => { + if (!showPrompt) return [PromptType.None, null]; + const currentSettings = settings || { ...VAULT_SETTINGS_DEFAULT }; + const { + biometricForcePasswordCount, + biometricForcePasswordMaxInterval, + biometricLastManualUnlock, + biometricUnlockCount + } = currentSettings; + const bioPassCount = parseInt(biometricForcePasswordCount, 10); + const bioInterval = parseInt(biometricForcePasswordMaxInterval, 10); + const bioPassCountExceeded = !isNaN(bioPassCount) && bioPassCount > 0 && biometricUnlockCount >= bioPassCount; + const bioIntervalPassed = !isNaN(bioInterval) && + bioInterval > 0 && + typeof biometricLastManualUnlock === "number" && + biometricLastManualUnlock < (Date.now() - (bioInterval * DAY_MS)); + if (biometricSourceID && biometricSourceID === sourceID) { + if (bioPassCountExceeded) { + return [PromptType.Password, t("dialog.password-prompt.biometric-fallback.unlock-count-exceeded")]; + } else if (bioIntervalPassed) { + return [PromptType.Password, t("dialog.password-prompt.biometric-fallback.unlock-period-exceeded")]; + } + return [PromptType.Biometric, null]; + } else if (sourceID) { + return [PromptType.Password, null]; + } + return [PromptType.None, null]; + }, [biometricSourceID, sourceID, settings, showPrompt]); + // Helpers + const updateVaultSettings = useCallback(async (): Promise => { + if (!sourceID) { + const newSettings = { + ...VAULT_SETTINGS_DEFAULT + }; + setSettings(newSettings); + return newSettings; + } + return getVaultSettings(sourceID) + .then(newSettings => { + setSettings(newSettings); + return newSettings; }) - .catch((err) => { - showError(t("notification.error.vault-settings-load")); + .catch(err => { + showError(t("error.vault-settings-fetch-failed")); logErr("Failed loading vault settings", err); + return { + ...VAULT_SETTINGS_DEFAULT + }; }); - }, [biometricSourceState.get()]); + }, [sourceID]); + // Effects useEffect(() => { - if (settings === null) return; - const showPrompt = showPromptState.get(); - const sourceID = biometricSourceState.get(); - if (!showPrompt || !sourceID || promptedBiometrics) return; - const biometricForcePasswordCount = Number(settings.biometricForcePasswordCount) || 0; - if ( - biometricForcePasswordCount > 0 && - biometricForcePasswordCount <= settings.biometricUnlockCount - ) { - setBiometricsPromptActive(false); - return; - } - const biometricForcePasswordTimeout = Number(settings.biometricForcePasswordMaxInterval) || 0; - if ( - biometricForcePasswordTimeout > 0 && - Date.now() > - settings.biometricLastManualUnlock + ONE_DAY_IN_MS * biometricForcePasswordTimeout - ) { - setBiometricsPromptActive(false); - return; + updateVaultSettings(); + }, [updateVaultSettings]); + useEffect(() => { + if (!showPrompt) return; + updateVaultSettings(); + }, [showPrompt, updateVaultSettings]); + const promptBiometrics = useCallback(async () => { + if (!biometricSourceID) { + throw new Error("Environment not ready for biometric prompt"); } - setBiometricsPromptActive(true); - setPromptedBiometrics(true); - getBiometricSourcePassword(sourceID) - .then((sourcePassword) => { - setBiometricsPromptActive(false); - if (!sourcePassword) return; - submitAndClose(sourcePassword, true); - }) - .catch((err) => { - setBiometricsPromptActive(false); + const sourcePassword = await getBiometricSourcePassword(biometricSourceID); + if (!sourcePassword) return; + submitPasswordPrompt(sourcePassword, true); + }, [biometricSourceID, submitPasswordPrompt]); + useEffect(() => { + if (!showPrompt || promptType !== PromptType.Biometric || promptedBiometrics || !biometricSourceID) return; + const timeout = setTimeout(() => { + setPromptedBiometrics(true); + promptBiometrics().catch((err) => { logErr(`Failed getting biometrics password for source: ${sourceID}`, err); const errInfo = Layerr.info(err); const message = (errInfo?.i18n && t(errInfo.i18n)) || err.message; showError(message); }); - }, [showPromptState.get(), biometricSourceState.get(), promptedBiometrics, settings]); + }, 250); + return () => { + clearTimeout(timeout); + }; + }, [biometricSourceID, promptBiometrics, promptType, showPrompt]); + // Render return ( - +
{t("dialog.password-prompt.title")}
- {!biometricsPromptActive && ( - - { - setShowPassword(true); - }} - onMouseLeave={() => { - setShowPassword(false); - }} - active={showPassword} - style={{ outline: "none", userSelect: "none" }} - /> - } - value={currentPassword} - onChange={(evt) => setCurrentPassword(evt.target.value)} - onKeyDown={handleKeyPress} - autoFocus - /> - + {promptType === PromptType.Password && ( + <> + {fallbackReason && ( + {fallbackReason} + )} + + { + setShowPassword(true); + }} + onMouseLeave={() => { + setShowPassword(false); + }} + active={showPassword} + style={{ outline: "none", userSelect: "none" }} + /> + } + value={currentPassword} + onChange={(evt) => setCurrentPassword(evt.target.value)} + onKeyDown={handleKeyPress} + autoFocus + /> + + )} - {biometricsPromptActive && ( + {promptType === PromptType.Biometric && (
-
diff --git a/source/renderer/components/VaultEditor.tsx b/source/renderer/components/VaultEditor.tsx index 254e7974..117e8762 100644 --- a/source/renderer/components/VaultEditor.tsx +++ b/source/renderer/components/VaultEditor.tsx @@ -1,12 +1,13 @@ import React, { useContext, useEffect, useMemo, useState } from "react"; import { useHistory } from "react-router-dom"; import { useState as useHookState } from "@hookstate/core"; +import { useSingleState } from "react-obstate"; import { Intent, NonIdealState, Tag } from "@blueprintjs/core"; import { VaultProvider, VaultUI, themes } from "@buttercup/ui"; import { VaultFacade, VaultSourceStatus } from "buttercup"; import styled, { ThemeProvider } from "styled-components"; import { SearchContext } from "./search/SearchContext"; -import { CURRENT_VAULT_ATTACHMENTS, VAULTS_LIST } from "../state/vaults"; +import { VAULTS_STATE } from "../state/vaults"; import { SAVING } from "../state/app"; import { fetchUpdatedFacade } from "../actions/facade"; import { saveVaultFacade } from "../actions/saveVault"; @@ -45,11 +46,11 @@ const LockedNonIdealState = styled(NonIdealState)` `; export function VaultEditor(props: VaultEditorProps) { - const { onUnlockRequest } = props; + const { onUnlockRequest, sourceID } = props; const history = useHistory(); const currentFacade = useCurrentFacade(); - const currentSupportsAttachmentsState = useHookState(CURRENT_VAULT_ATTACHMENTS); - const vaultListState = useHookState(VAULTS_LIST); + const [currentSupportsAttachments] = useSingleState(VAULTS_STATE, "currentVaultAttachments"); + const [vaultList] = useSingleState(VAULTS_STATE, "vaultsList"); const savingState = useHookState(SAVING); const { addAttachments, @@ -57,18 +58,17 @@ export function VaultEditor(props: VaultEditorProps) { deleteAttachment, downloadAttachment, previewAttachment - } = useAttachments(props.sourceID); + } = useAttachments(sourceID); const vaultItem = useMemo(() => { - const vaultList = vaultListState.get(); - return vaultList.find(item => item.id === props.sourceID) || null; - }, [vaultListState.get()]); + return vaultList.find(item => item.id === sourceID) || null; + }, [sourceID, vaultList]); const themeType = useTheme(); const [currentlyEditing, setCurrentlyEditing] = useState(false); useEffect(() => { if (vaultItem && vaultItem.state === VaultSourceStatus.Unlocked) { fetchUpdatedFacade(vaultItem.id); } - }, [props.sourceID, vaultItem?.state]); + }, [sourceID, vaultItem?.state]); useEffect(() => { logInfo(`Toggling auto-update for vault editing (editing=${currentlyEditing}, auto-update=${!currentlyEditing})`); toggleAutoUpdate(!currentlyEditing).catch(err => { @@ -76,8 +76,8 @@ export function VaultEditor(props: VaultEditorProps) { }); }, [currentlyEditing]); useEffect(() => { - setSelectedSource(props.sourceID); - }, [props.sourceID]); + setSelectedSource(sourceID); + }, [sourceID]); // Search const { resetSelection, @@ -131,7 +131,7 @@ export function VaultEditor(props: VaultEditorProps) { {currentFacade && ( (null); - const [currentTitle, setCurrentTitle] = useState(null); - const vaultsState = useHookState>(VAULTS_LIST); + const [removingSourceID, setRemovingSourceID] = useState(null); + const [currentTitle, setCurrentTitle] = useState(null); + const [vaults] = useSingleState(VAULTS_STATE, "vaultsList"); + const [, setCurrentVault] = useSingleState(VAULTS_STATE, "currentVault"); const handleSourceUnlockRequest = useCallback((sourceID: VaultSourceID) => { - const vault = vaultsState.get().find(vault => vault.id === sourceID); + const vault = vaults.find(vault => vault.id === sourceID); + if (!vault) return; if (vault.state === VaultSourceStatus.Locked) { unlockVaultSource(sourceID).catch(handleError); } - }, [vaultsState]); + }, [vaults]); const handleSourceAdd = useCallback(() => { showAddVaultMenu(true); }, [history]); const handleSourceLock = useCallback((sourceID: VaultSourceID) => { - const vault = vaultsState.get().find(vault => vault.id === sourceID); + const vault = vaults.find(vault => vault.id === sourceID); + if (!vault) return; if (vault.state === VaultSourceStatus.Unlocked) { lockVaultSource(sourceID).catch(handleError); } - }, []); + }, [vaults]); const handleSourceSelect = useCallback((sourceID: VaultSourceID) => { history.push(`/source/${sourceID}`); - }, [history, id]); + setCurrentVault(sourceID); + }, [history, id, setCurrentVault]); const handleSourcesReoder = useCallback((newTabsOrder: Array) => { setVaultSourcesOrder(newTabsOrder.map(tab => tab.id)).catch(err => { logErr("Failed reordering vaults", err); @@ -70,10 +74,10 @@ export function VaultManagement() { }, []); const handleSourceRemove = useCallback((sourceID: VaultSourceID) => { setRemovingSourceID(sourceID); - setCurrentTitle(vaultsState.get().find(source => source.id === sourceID).name); - }, [vaultsState]); + setCurrentTitle(vaults.find(source => source.id === sourceID)?.name ?? ""); + }, [vaults]); const handleSourceRemoveConfirm = useCallback((remove: boolean) => { - if (remove) { + if (remove && removingSourceID) { removeVaultSource(removingSourceID) .then(() => { showSuccess(t("notification.vault-removed", { name: currentTitle })); diff --git a/source/renderer/components/VaultSettingsDialog.tsx b/source/renderer/components/VaultSettingsDialog.tsx index b963efbe..389bc544 100644 --- a/source/renderer/components/VaultSettingsDialog.tsx +++ b/source/renderer/components/VaultSettingsDialog.tsx @@ -226,34 +226,43 @@ export function VaultSettingsDialog() {
- - { - setSettings({ - ...naiveClone(settings), - biometricForcePasswordMaxInterval: valueAsString - }) - }} - /> - - - { - setSettings({ - ...naiveClone(settings), - biometricForcePasswordCount: valueAsString - }) - }} - placeholder={t("vault-settings.biometric.enable-password-prompt-count.placeholder")} - value={settings.biometricForcePasswordCount} - /> - + {state === VaultSourceStatus.Unlocked && ( + <> + + { + setSettings({ + ...naiveClone(settings), + biometricForcePasswordMaxInterval: valueAsString + }) + }} + /> + + + { + setSettings({ + ...naiveClone(settings), + biometricForcePasswordCount: valueAsString + }) + }} + placeholder={t("vault-settings.biometric.enable-password-prompt-count.placeholder")} + value={settings.biometricForcePasswordCount} + /> + + + ) || ( + + + {t("vault-settings.not-unlocked")} + + )} ) return ( diff --git a/source/renderer/components/navigation/AutoNav.tsx b/source/renderer/components/navigation/AutoNav.tsx index 73144c5e..e1326dcd 100644 --- a/source/renderer/components/navigation/AutoNav.tsx +++ b/source/renderer/components/navigation/AutoNav.tsx @@ -1,29 +1,30 @@ import { useEffect } from "react"; import { useHistory } from "react-router-dom"; -import { useState as useHookState } from "@hookstate/core"; +import { useSingleState } from "react-obstate"; import { sortVaults } from "../../library/vault"; import { logInfo } from "../../library/log"; -import { CURRENT_VAULT, VAULTS_LIST } from "../../state/vaults"; +import { VAULTS_STATE } from "../../state/vaults"; export function AutoNav() { const history = useHistory(); - const currentVaultState = useHookState(CURRENT_VAULT); - const vaultsState = useHookState(VAULTS_LIST); + const [currentVault, setCurrentVault] = useSingleState(VAULTS_STATE, "currentVault"); + const [vaults] = useSingleState(VAULTS_STATE, "vaultsList"); useEffect(() => { - const currentVault = currentVaultState.get(); if (currentVault) { logInfo(`Auto-nav: Current vault available: ${currentVault}`); history.push(`/source/${currentVault}`); + setCurrentVault(currentVault); return; } - const vaults = sortVaults(vaultsState.get()); - if (vaults.length > 0) { - logInfo(`Auto-nav: First vault in order: ${vaults[0].id}`); - history.push(`/source/${vaults[0].id}`); + const sortedVaults = sortVaults(vaults); + if (sortedVaults.length > 0) { + logInfo(`Auto-nav: First vault in order: ${sortedVaults[0].id}`); + history.push(`/source/${sortedVaults[0].id}`); + setCurrentVault(sortedVaults[0].id); return; } logInfo("Auto-nav: No vaults, new-vault page"); history.push("/add-vault"); - }, [history, currentVaultState, vaultsState]); + }, [history, currentVault, setCurrentVault, vaults]); return null; } diff --git a/source/renderer/components/navigation/VaultTabs.tsx b/source/renderer/components/navigation/VaultTabs.tsx index 0815dbb6..b14a2196 100644 --- a/source/renderer/components/navigation/VaultTabs.tsx +++ b/source/renderer/components/navigation/VaultTabs.tsx @@ -1,15 +1,14 @@ import React, { useMemo } from "react"; -import { useState as useHookState } from "@hookstate/core"; import { VaultSourceID, VaultSourceStatus } from "buttercup"; +import { useSingleState } from "react-obstate"; import { Tabs } from "@buttercup/ui"; import { Intent, Menu, MenuDivider, MenuItem } from "@blueprintjs/core"; import { sortVaults } from "../../library/vault"; import { getIconForProvider } from "../../library/icons"; -import { VAULTS_LIST } from "../../state/vaults"; +import { VAULTS_STATE } from "../../state/vaults"; import { showVaultSettingsForSource } from "../../state/vaultSettings"; import { t } from "../../../shared/i18n/trans"; -import { VaultSourceDescription } from "../../types"; export interface Tab { content: string; @@ -41,8 +40,8 @@ function TabMenu(props: TabMenuProps) { onRemoveVault, onUnlockVault } = props; - const vaultsState = useHookState>(VAULTS_LIST); - const vaultDetails = useMemo(() => vaultsState.get().find(item => item.id === id), [id, vaultsState]); + const [vaults] = useSingleState(VAULTS_STATE, "vaultsList"); + const vaultDetails = useMemo(() => vaults.find(item => item.id === id), [id, vaults]); return ( >(VAULTS_LIST); - const vaults = useMemo(() => sortVaults([...vaultsState.get()]), [vaultsState]); + const [rawVaults] = useSingleState(VAULTS_STATE, "vaultsList"); + const vaults = useMemo(() => sortVaults(rawVaults), [rawVaults]); const tabs: Array = useMemo(() => vaults.map(vault => ({ content: vault.name, id: vault.id, diff --git a/source/renderer/components/search/SearchContext.tsx b/source/renderer/components/search/SearchContext.tsx index b897beba..cd5783f3 100644 --- a/source/renderer/components/search/SearchContext.tsx +++ b/source/renderer/components/search/SearchContext.tsx @@ -1,16 +1,25 @@ -import React, { useCallback, useState } from "react"; +import { EntryID, GroupID } from "buttercup"; +import React, { createContext, useCallback, useState } from "react"; -export const SearchContext = React.createContext(null); +interface SearchContextState { + resetSelection: () => void; + selectedEntryID: EntryID | null; + selectedGroupID: GroupID | null; + setSelectedEntryID: (id: EntryID | null) => void; + setSelectedGroupID: (id: GroupID | null) => void; +} + +export const SearchContext = createContext({} as SearchContextState); export function SearchProvider(props) { const { children } = props; - const [selectedGroupID, setSelectedGroupID] = useState(null); - const [selectedEntryID, setSelectedEntryID] = useState(null); + const [selectedGroupID, setSelectedGroupID] = useState(null); + const [selectedEntryID, setSelectedEntryID] = useState(null); const resetSelection = useCallback(() => { setSelectedEntryID(null); setSelectedGroupID(null); }, []); - const context = { + const context: SearchContextState = { resetSelection, selectedEntryID, selectedGroupID, diff --git a/source/renderer/components/standalone/BiometricRegistrationDialog.tsx b/source/renderer/components/standalone/BiometricRegistrationDialog.tsx index d9783858..9f91f7bd 100644 --- a/source/renderer/components/standalone/BiometricRegistrationDialog.tsx +++ b/source/renderer/components/standalone/BiometricRegistrationDialog.tsx @@ -2,15 +2,16 @@ import React, { useCallback, useState } from "react"; import { useState as useHookState } from "@hookstate/core"; import { Button, Classes, Dialog, FormGroup, InputGroup, Intent } from "@blueprintjs/core"; import { Layerr } from "layerr"; +import { useSingleState } from "react-obstate"; import { SHOW_REGISTER_PROMPT } from "../../state/biometrics"; -import { CURRENT_VAULT } from "../../state/vaults"; +import { VAULTS_STATE } from "../../state/vaults"; import { registerBiometricUnlock } from "../../services/biometrics"; import { showError, showSuccess } from "../../services/notifications"; import { t } from "../../../shared/i18n/trans"; export function BiometricRegistrationDialog() { const showPromptState = useHookState(SHOW_REGISTER_PROMPT); - const currentVaultState = useHookState(CURRENT_VAULT); + const [currentVault] = useSingleState(VAULTS_STATE, "currentVault"); const [password, setPassword] = useState(""); const [prompting, setPrompting] = useState(false); const close = useCallback(() => { @@ -19,8 +20,9 @@ export function BiometricRegistrationDialog() { setPrompting(false); }, []); const submitPassword = useCallback(() => { + if (!currentVault) return; setPrompting(true); - registerBiometricUnlock(currentVaultState.get(), password) + registerBiometricUnlock(currentVault, password) .then(() => { close(); showSuccess(t("dialog.biometric-reg.success")); @@ -30,7 +32,7 @@ export function BiometricRegistrationDialog() { showError(info?.i18n && t(info.i18n) || err.message); setPrompting(false); }); - }, [close, currentVaultState]); + }, [close, currentVault]); const handleKeyPress = useCallback(event => { if (event.key === "Enter") { submitPassword(); diff --git a/source/renderer/ipc.ts b/source/renderer/ipc.ts index 4df7714e..5c58f0ba 100644 --- a/source/renderer/ipc.ts +++ b/source/renderer/ipc.ts @@ -1,7 +1,7 @@ import { ipcRenderer } from "electron"; import { UpdateInfo } from "electron-updater"; import { VaultSourceID } from "buttercup"; -import { getCurrentSourceID, setCurrentVault, setVaultsList } from "./state/vaults"; +import { VAULTS_STATE } from "./state/vaults"; import { showAddVaultMenu } from "./state/addVault"; import { showPreferences } from "./state/preferences"; import { showAbout } from "./state/about"; @@ -71,7 +71,7 @@ ipcRenderer.on("open-preferences", (evt) => { }); ipcRenderer.on("open-search", (evt) => { - const currentSourceID = getCurrentSourceID(); + const currentSourceID = VAULTS_STATE.currentVault; if (!currentSourceID) return; setSearchVisible(true); }); @@ -89,7 +89,7 @@ ipcRenderer.on("set-busy", (_, busy: boolean) => { }); ipcRenderer.on("source-updated", (evt, sourceID) => { - const currentSourceID = getCurrentSourceID(); + const currentSourceID = VAULTS_STATE.currentVault; if (sourceID === currentSourceID) { fetchUpdatedFacade(sourceID); } @@ -126,11 +126,11 @@ ipcRenderer.on("update-progress", (evt, prog) => { ipcRenderer.on("vaults-list", (evt, payload) => { const vaults = JSON.parse(payload) as Array; logInfo(`Updated ${vaults.length} vaults from back-end`); - setVaultsList(vaults); + VAULTS_STATE.vaultsList = vaults; updateVaultsBiometricsStates(vaults); - const currentSourceID = getCurrentSourceID(); + const currentSourceID = VAULTS_STATE.currentVault; if (currentSourceID && !vaults.find((vault) => vault.id === currentSourceID)) { logInfo("Resetting current vault as it no longer exists on back-end"); - setCurrentVault(null); + VAULTS_STATE.currentVault = null; } }); diff --git a/source/renderer/services/facade.ts b/source/renderer/services/facade.ts index bd6f68d14..95da3377 100644 --- a/source/renderer/services/facade.ts +++ b/source/renderer/services/facade.ts @@ -1,10 +1,10 @@ import { VaultFacade } from "buttercup"; import EventEmitter from "eventemitter3"; -let __currentFacade = null, - __emitter: EventEmitter = null; +let __currentFacade: VaultFacade | null = null, + __emitter: EventEmitter | null = null; -export function getCurrentFacade(): VaultFacade { +export function getCurrentFacade(): VaultFacade | null { return __currentFacade; } @@ -15,7 +15,7 @@ export function getFacadeEmitter(): EventEmitter { return __emitter; } -export function setCurrentFacade(facade: VaultFacade) { +export function setCurrentFacade(facade: VaultFacade | null) { __currentFacade = facade; - __emitter.emit("facadeUpdated"); + getFacadeEmitter().emit("facadeUpdated"); } diff --git a/source/renderer/state/password.ts b/source/renderer/state/password.ts index 072d0245..67f250f5 100644 --- a/source/renderer/state/password.ts +++ b/source/renderer/state/password.ts @@ -1,18 +1,10 @@ -import { State, createState } from "@hookstate/core"; import { VaultSourceID } from "buttercup"; +import { createStateObject } from "obstate"; -export const PASSWORD_VIA_BIOMETRIC_SOURCE: State = createState( - null as VaultSourceID -); -export const SHOW_PROMPT: State = createState(false as boolean); - -export function setBiometricSourceID(sourceID: VaultSourceID) { - PASSWORD_VIA_BIOMETRIC_SOURCE.set(sourceID); -} - -export function showPasswordPrompt(show = true) { - SHOW_PROMPT.set(show); - if (!show) { - PASSWORD_VIA_BIOMETRIC_SOURCE.set(null); - } -} +export const PASSWORD_STATE = createStateObject<{ + passwordViaBiometricSource: VaultSourceID | null; + showPrompt: boolean; +}>({ + passwordViaBiometricSource: null, + showPrompt: false +}); diff --git a/source/renderer/state/vaults.ts b/source/renderer/state/vaults.ts index 61ebd150..8e1faf07 100644 --- a/source/renderer/state/vaults.ts +++ b/source/renderer/state/vaults.ts @@ -1,25 +1,13 @@ -import { State, createState } from "@hookstate/core"; import { VaultSourceID } from "buttercup"; +import { createStateObject } from "obstate"; import { VaultSourceDescription } from "../types"; -export const CURRENT_VAULT: State = createState(null as VaultSourceID | null); -export const CURRENT_VAULT_ATTACHMENTS: State = createState(false as boolean); -export const VAULTS_LIST: State> = createState( - [] as Array -); - -export function getCurrentSourceID(): VaultSourceID | null { - return CURRENT_VAULT.get(); -} - -export function setCurrentVault(sourceID: VaultSourceID | null) { - CURRENT_VAULT.set(sourceID); -} - -export function setCurrentVaultSupportsAttachments(supports: boolean) { - CURRENT_VAULT_ATTACHMENTS.set(supports); -} - -export function setVaultsList(vaults: Array) { - VAULTS_LIST.set([...vaults]); -} +export const VAULTS_STATE = createStateObject<{ + currentVault: VaultSourceID | null; + currentVaultAttachments: boolean; + vaultsList: Array; +}>({ + currentVault: null, + currentVaultAttachments: false, + vaultsList: [] +}); diff --git a/source/shared/i18n/translations/en.json b/source/shared/i18n/translations/en.json index 4940eff9..16a94779 100644 --- a/source/shared/i18n/translations/en.json +++ b/source/shared/i18n/translations/en.json @@ -185,6 +185,10 @@ "title": "Add Vault File" }, "password-prompt": { + "biometric-fallback": { + "unlock-count-exceeded": "Maximum biometric unlock count exceeded: password required", + "unlock-period-exceeded": "Biometric unlock time period expired: password required" + }, "button-cancel": "Cancel", "button-cancel-title": "Cancel unlock", "button-unlock": "Unlock", @@ -202,7 +206,8 @@ "biometric-invalid-password": "An invalid password was provided for enabling biometrics on a vault", "biometric-store-failed": "Failed storing biometric details", "biometric-unlock-failed": "Failed unlocking vault using biometrics", - "fatal-boundary": "A fatal error has occurred - we're sorry this has happened. Please check out the details below in case they help diagnose the issue:" + "fatal-boundary": "A fatal error has occurred - we're sorry this has happened. Please check out the details below in case they help diagnose the issue:", + "vault-settings-fetch-failed": "Failed fetching vault settings" }, "file-host": { "code-copied": "File host code copied", @@ -330,6 +335,20 @@ "switch": "Enable local backups", "title": "Backup" }, + "biometric": { + "description": "Configure password requirement when using biometrics", + "enable-password-prompt-count": { + "label": "Maximum number of biometric unlocks before prompting to type in password", + "placeholder": "Number of biometric unlock", + "helper": "Leave blank or set it to '0' to disable" + }, + "enable-password-prompt-timeout": { + "label": "Maximum number of days before prompting to type in password", + "placeholder": "Number of days", + "helper": "Leave blank or set it to '0' to disable" + }, + "title": "Biometrics" + }, "format": { "a-description": "Format A is the original Buttercup vault format that uses deltas to store vault structure.", "b-description": "Format B is the new Buttercup vault format that uses a JSON structure to manage vault structure and history.", @@ -339,20 +358,6 @@ "title": "Format", "upgrade-button": "Upgrade" }, - "biometric": { - "title": "Biometric", - "description": "Configure when requiring to type in password", - "enable-password-prompt-timeout": { - "label": "Maximum number of days before prompting to type in password", - "placeholder": "Number of days", - "helper": "Leave blank or set it to '0' to disable" - }, - "enable-password-prompt-count": { - "label": "Maximum number of biometric unlocks before prompting to type in password", - "placeholder": "Number of biometric unlock", - "helper": "Leave blank or set it to '0' to disable" - } - }, "not-unlocked": "Vault must be unlocked to access this area.", "title": "Vault Settings: {{title}}" }, diff --git a/source/shared/types.ts b/source/shared/types.ts index 9d714df4..d2d408cf 100644 --- a/source/shared/types.ts +++ b/source/shared/types.ts @@ -64,12 +64,12 @@ export interface UpdateProgressInfo { } export interface VaultSettingsLocal { - localBackup: boolean; - localBackupLocation: null | string; - biometricForcePasswordMaxInterval: string; biometricForcePasswordCount: string; - biometricLastManualUnlock: number; + biometricForcePasswordMaxInterval: string; + biometricLastManualUnlock: number | null; biometricUnlockCount: number; + localBackup: boolean; + localBackupLocation: null | string; } export interface VaultSourceDescription {