From 4fdf7ce92c98ba6fa2e600d50b62467eb4929cb5 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Thu, 13 Mar 2025 13:31:04 +0100 Subject: [PATCH] Show autofill popup dismiss button when vault is locked (#682) --- .../src/entrypoints/background.ts | 3 +- .../background/VaultMessageHandler.ts | 17 ++++ browser-extension/src/entrypoints/content.ts | 16 ++-- .../src/entrypoints/contentScript/Form.ts | 2 +- .../src/entrypoints/contentScript/Popup.ts | 88 +++++++++++++++++-- .../src/entrypoints/contentScript/style.css | 39 +++++--- .../entrypoints/popup/context/AuthContext.tsx | 4 + .../entrypoints/popup/pages/AuthSettings.tsx | 45 ++++++++++ .../src/entrypoints/popup/pages/Settings.tsx | 14 +-- .../src/entrypoints/popup/pages/Unlock.tsx | 5 ++ 10 files changed, 193 insertions(+), 40 deletions(-) diff --git a/browser-extension/src/entrypoints/background.ts b/browser-extension/src/entrypoints/background.ts index 1fb31986f..f5adfcbca 100644 --- a/browser-extension/src/entrypoints/background.ts +++ b/browser-extension/src/entrypoints/background.ts @@ -2,7 +2,7 @@ import { browser } from "wxt/browser"; import { defineBackground } from 'wxt/sandbox'; import { onMessage } from "webext-bridge/background"; import { setupContextMenus, handleContextMenuClick } from './background/ContextMenu'; -import { handleClearVault, handleCreateIdentity, handleGetCredentials, handleGetDefaultEmailDomain, handleGetDerivedKey, handleGetVault, handleStoreVault, handleSyncVault } from './background/VaultMessageHandler'; +import { handleCheckAuthStatus, handleClearVault, handleCreateIdentity, handleGetCredentials, handleGetDefaultEmailDomain, handleGetDerivedKey, handleGetVault, handleStoreVault, handleSyncVault } from './background/VaultMessageHandler'; import { handleOpenPopup, handlePopupWithCredential } from './background/PopupMessageHandler'; export default defineBackground({ @@ -17,6 +17,7 @@ export default defineBackground({ ); // Listen for messages using webext-bridge + onMessage('CHECK_AUTH_STATUS', () => handleCheckAuthStatus()); onMessage('STORE_VAULT', ({ data }) => handleStoreVault(data)); onMessage('SYNC_VAULT', () => handleSyncVault()); onMessage('GET_VAULT', () => handleGetVault()); diff --git a/browser-extension/src/entrypoints/background/VaultMessageHandler.ts b/browser-extension/src/entrypoints/background/VaultMessageHandler.ts index 00d4ecc20..198015ce2 100644 --- a/browser-extension/src/entrypoints/background/VaultMessageHandler.ts +++ b/browser-extension/src/entrypoints/background/VaultMessageHandler.ts @@ -11,6 +11,23 @@ import { VaultResponse as messageVaultResponse } from '../../utils/types/messagi import { CredentialsResponse as messageCredentialsResponse } from '../../utils/types/messaging/CredentialsResponse'; import { DefaultEmailDomainResponse as messageDefaultEmailDomainResponse } from '../../utils/types/messaging/DefaultEmailDomainResponse'; +/** + * Check if the user is logged in and if the vault is locked. + */ +export async function handleCheckAuthStatus() : Promise<{ isLoggedIn: boolean, isVaultLocked: boolean }> { + const username = await storage.getItem('local:username'); + const accessToken = await storage.getItem('local:accessToken'); + const vaultData = await storage.getItem('session:encryptedVault'); + + const isLoggedIn = username !== null && accessToken !== null; + const isVaultLocked = isLoggedIn && vaultData !== null; + + return { + isLoggedIn, + isVaultLocked + }; +} + /** * Store the vault in browser storage. */ diff --git a/browser-extension/src/entrypoints/content.ts b/browser-extension/src/entrypoints/content.ts index 60c6791c1..6f36e961d 100644 --- a/browser-extension/src/entrypoints/content.ts +++ b/browser-extension/src/entrypoints/content.ts @@ -1,7 +1,7 @@ import './contentScript/style.css'; import { FormDetector } from '../utils/formDetector/FormDetector'; -import { isAutoShowPopupDisabled, openAutofillPopup, removeExistingPopup } from './contentScript/Popup'; -import { canShowPopup, injectIcon } from './contentScript/Form'; +import { isAutoShowPopupEnabled, openAutofillPopup, removeExistingPopup } from './contentScript/Popup'; +import { injectIcon, popupDebounceTimeHasPassed } from './contentScript/Form'; import { onMessage } from "webext-bridge/content-script"; import { BoolResponse as messageBoolResponse } from '../utils/types/messaging/BoolResponse'; import { defineContentScript } from 'wxt/sandbox'; @@ -42,22 +42,16 @@ export default defineContentScript({ const target = e.target as HTMLInputElement; const textInputTypes = ['text', 'email', 'tel', 'password', 'search', 'url']; - if (target.tagName === 'INPUT' && - textInputTypes.includes(target.type) && - !target.dataset.aliasvaultIgnore) { + if (target.tagName === 'INPUT' && textInputTypes.includes(target.type) && !target.dataset.aliasvaultIgnore) { const formDetector = new FormDetector(document, target); - if (!formDetector.containsLoginForm()) { return; } injectIcon(target, container); - const isDisabled = await isAutoShowPopupDisabled(); - const canShow = canShowPopup(); - - // Only show popup if it's not disabled and the popup can be shown - if (!isDisabled && canShow) { + // Only show popup if its enabled and debounce time has passed. + if (await isAutoShowPopupEnabled() && popupDebounceTimeHasPassed()) { openAutofillPopup(target, container); } } diff --git a/browser-extension/src/entrypoints/contentScript/Form.ts b/browser-extension/src/entrypoints/contentScript/Form.ts index b05f31e53..ac67a686c 100644 --- a/browser-extension/src/entrypoints/contentScript/Form.ts +++ b/browser-extension/src/entrypoints/contentScript/Form.ts @@ -14,7 +14,7 @@ let popupDebounceTime = 0; /** * Check if popup can be shown based on debounce time. */ -export function canShowPopup() : boolean { +export function popupDebounceTimeHasPassed() : boolean { if (Date.now() < popupDebounceTime) { return false; } diff --git a/browser-extension/src/entrypoints/contentScript/Popup.ts b/browser-extension/src/entrypoints/contentScript/Popup.ts index 6a8e17f21..68016e1a5 100644 --- a/browser-extension/src/entrypoints/contentScript/Popup.ts +++ b/browser-extension/src/entrypoints/contentScript/Popup.ts @@ -443,19 +443,25 @@ export function createAutofillPopup(input: HTMLInputElement, credentials: Creden * Create vault locked popup. */ export function createVaultLockedPopup(input: HTMLInputElement, rootContainer: HTMLElement): void { - const popup = createBasePopup(input, rootContainer); - popup.classList.add('av-vault-locked'); - - // Make the whole popup clickable to open the main extension login popup. - popup.addEventListener('click', () => { + /** + * Handle unlock click. + */ + const handleUnlockClick = () : void => { sendMessage('OPEN_POPUP', {}, 'background'); removeExistingPopup(rootContainer); - }); + } + + const popup = createBasePopup(input, rootContainer); + popup.classList.add('av-vault-locked'); // Create container for message and button const container = document.createElement('div'); container.className = 'av-vault-locked-container'; + // Make the entire container clickable + container.addEventListener('click', handleUnlockClick); + container.style.cursor = 'pointer'; + // Add message const messageElement = document.createElement('div'); messageElement.className = 'av-vault-locked-message'; @@ -472,10 +478,37 @@ export function createVaultLockedPopup(input: HTMLInputElement, rootContainer: H `; - container.appendChild(button); + + // Add the container to the popup popup.appendChild(container); + // Add close button as a separate element positioned to the right + const closeButton = document.createElement('button'); + closeButton.className = 'av-button av-button-close av-vault-locked-close'; + closeButton.title = 'Dismiss popup'; + closeButton.innerHTML = ` + + + + + `; + + // Position the close button to the right of the container + closeButton.style.position = 'absolute'; + closeButton.style.right = '8px'; + closeButton.style.top = '50%'; + closeButton.style.transform = 'translateY(-50%)'; + + // Handle close button click + closeButton.addEventListener('click', async (e) => { + e.stopPropagation(); // Prevent opening the unlock popup + await dismissVaultLockedPopup(); + removeExistingPopup(rootContainer); + }); + + popup.appendChild(closeButton); + /** * Add event listener to document to close popup when clicking outside. */ @@ -600,17 +633,35 @@ function createCredentialList(credentials: Credential[], input: HTMLInputElement export const DISABLED_SITES_KEY = 'local:aliasvault_disabled_sites'; export const GLOBAL_POPUP_ENABLED_KEY = 'local:aliasvault_global_popup_enabled'; +export const VAULT_LOCKED_DISMISS_UNTIL_KEY = 'local:aliasvault_vault_locked_dismiss_until'; /** * Check if auto-popup is disabled for current site */ -export async function isAutoShowPopupDisabled(): Promise { +export async function isAutoShowPopupEnabled(): Promise { const disabledSites = await storage.getItem(DISABLED_SITES_KEY) as string[] ?? []; const globalPopupEnabled = await storage.getItem(GLOBAL_POPUP_ENABLED_KEY) ?? true; const currentHostname = window.location.hostname; - return !globalPopupEnabled || disabledSites.includes(currentHostname); + if (!globalPopupEnabled) { + // Popup is disabled for all sites. + return false; + } + + if (disabledSites.includes(currentHostname)) { + // Popup is disabled for current site. + return false; + } + + // Check time-based dismissal + const dismissUntil = await storage.getItem(VAULT_LOCKED_DISMISS_UNTIL_KEY) as number; + if (dismissUntil && Date.now() < dismissUntil) { + // Popup is dismissed for a certain amount of time. + return false; + } + + return true; } /** @@ -926,3 +977,22 @@ function detectMimeType(bytes: Uint8Array): string { return 'image/x-icon'; } + +/** + * Dismiss vault locked popup for 4 hours if user is logged in but vault is locked, + * or for 3 days if user is not logged in. + */ +export async function dismissVaultLockedPopup(): Promise { + // First check if user is logged in but vault is locked, or not logged in at all + const authStatus = await sendMessage('CHECK_AUTH_STATUS', {}, 'background') as { isLoggedIn: boolean, isVaultLocked: boolean }; + + if (authStatus.isLoggedIn && authStatus.isVaultLocked) { + // User is logged in but vault is locked - dismiss for 4 hours + const fourHoursFromNow = Date.now() + (4 * 60 * 60 * 1000); + await storage.setItem(VAULT_LOCKED_DISMISS_UNTIL_KEY, fourHoursFromNow); + } else if (!authStatus.isLoggedIn) { + // User is not logged in - dismiss for 3 days + const threeDaysFromNow = Date.now() + (3 * 24 * 60 * 60 * 1000); + await storage.setItem(VAULT_LOCKED_DISMISS_UNTIL_KEY, threeDaysFromNow); + } +} diff --git a/browser-extension/src/entrypoints/contentScript/style.css b/browser-extension/src/entrypoints/contentScript/style.css index 68c2459d7..5766fbb6c 100644 --- a/browser-extension/src/entrypoints/contentScript/style.css +++ b/browser-extension/src/entrypoints/contentScript/style.css @@ -79,7 +79,7 @@ body { padding: 10px 16px; border-radius: 4px; transition: background-color 0.2s ease; - min-width: 0; /* Enable text truncation */ + min-width: 0; } .av-credential-logo { @@ -91,8 +91,8 @@ body { display: flex; flex-direction: column; flex-grow: 1; - min-width: 0; /* Enable text truncation */ - margin-right: 8px; /* Add space between text and popout icon */ + min-width: 0; + margin-right: 8px; } .av-service-name { @@ -121,7 +121,7 @@ body { margin-right: 16px; opacity: 0.6; border-radius: 4px; - flex-shrink: 0; /* Prevent icon from shrinking */ + flex-shrink: 0; color: #ffffff; transition: opacity 0.2s ease, background-color 0.2s ease, color 0.2s ease; } @@ -209,7 +209,6 @@ body { outline: none; line-height: 1; text-align: center; - padding: 6px 12px; } .av-search-input::placeholder { @@ -224,7 +223,7 @@ body { /* Vault Locked Popup */ .av-vault-locked { padding: 12px 16px; - cursor: pointer; + position: relative; } .av-vault-locked:hover { @@ -234,28 +233,46 @@ body { .av-vault-locked-container { display: flex; align-items: center; - position: relative; + padding-right: 32px; + width: 100%; + transition: background-color 0.2s ease; + border-radius: 4px; } .av-vault-locked-message { color: #d1d5db; font-size: 14px; - padding-right: 32px; + flex-grow: 1; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; } .av-vault-locked-button { - position: absolute; - right: 0; background: none; border: none; cursor: pointer; padding: 4px; + padding-right: 28px; display: flex; align-items: center; justify-content: center; color: #d68338; border-radius: 4px; + margin-left: 8px; +} + +.av-vault-locked-close { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + padding: 4px; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + z-index: 1; + border: 1px solid #6f6f6f; } /* Create Name Popup */ @@ -404,4 +421,4 @@ body { @keyframes fadeOut { 0% { opacity: 1; transform: scale(1.02); } 100% { opacity: 0; transform: scale(1); } -} +} \ No newline at end of file diff --git a/browser-extension/src/entrypoints/popup/context/AuthContext.tsx b/browser-extension/src/entrypoints/popup/context/AuthContext.tsx index 843081428..a433e9b39 100644 --- a/browser-extension/src/entrypoints/popup/context/AuthContext.tsx +++ b/browser-extension/src/entrypoints/popup/context/AuthContext.tsx @@ -2,6 +2,7 @@ import React, { createContext, useContext, useState, useEffect, useMemo, useCall import { useDb } from './DbContext'; import { storage } from 'wxt/storage'; import { sendMessage } from 'webext-bridge/popup'; +import { VAULT_LOCKED_DISMISS_UNTIL_KEY } from '@/entrypoints/contentScript/Popup'; type AuthContextType = { isLoggedIn: boolean; @@ -66,6 +67,9 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children */ const login = useCallback(async () : Promise => { setIsLoggedIn(true); + + // Clear dismiss until (which can be enabled after user has dimissed vault is locked popup) to ensure popup is shown. + await storage.setItem(VAULT_LOCKED_DISMISS_UNTIL_KEY, 0); }, []); /** diff --git a/browser-extension/src/entrypoints/popup/pages/AuthSettings.tsx b/browser-extension/src/entrypoints/popup/pages/AuthSettings.tsx index a95ef2031..782e4fe69 100644 --- a/browser-extension/src/entrypoints/popup/pages/AuthSettings.tsx +++ b/browser-extension/src/entrypoints/popup/pages/AuthSettings.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import { AppInfo } from '../../../utils/AppInfo'; import { storage } from 'wxt/storage'; +import { GLOBAL_POPUP_ENABLED_KEY, DISABLED_SITES_KEY, VAULT_LOCKED_DISMISS_UNTIL_KEY } from '../../contentScript/Popup'; type ApiOption = { label: string; @@ -19,6 +20,7 @@ const AuthSettings: React.FC = () => { const [selectedOption, setSelectedOption] = useState(''); const [customUrl, setCustomUrl] = useState(''); const [customClientUrl, setCustomClientUrl] = useState(''); + const [isGloballyEnabled, setIsGloballyEnabled] = useState(true); useEffect(() => { /** @@ -27,6 +29,15 @@ const AuthSettings: React.FC = () => { const loadStoredSettings = async () : Promise => { const apiUrl = await storage.getItem('local:apiUrl') as string; const clientUrl = await storage.getItem('local:clientUrl') as string; + const globallyEnabled = await storage.getItem(GLOBAL_POPUP_ENABLED_KEY) !== false; // Default to true if not set + const dismissUntil = await storage.getItem(VAULT_LOCKED_DISMISS_UNTIL_KEY) as number; + + if (dismissUntil) { + setIsGloballyEnabled(false); + } else { + setIsGloballyEnabled(globallyEnabled); + } + const matchingOption = DEFAULT_OPTIONS.find(opt => opt.value === apiUrl); if (matchingOption) { @@ -74,6 +85,23 @@ const AuthSettings: React.FC = () => { await storage.setItem('local:clientUrl', value); }; + /** + * Toggle global popup. + */ + const toggleGlobalPopup = async () : Promise => { + const newGloballyEnabled = !isGloballyEnabled; + + await storage.setItem(GLOBAL_POPUP_ENABLED_KEY, newGloballyEnabled); + + if (newGloballyEnabled) { + // Reset all disabled sites when enabling globally + await storage.setItem(DISABLED_SITES_KEY, []); + await storage.setItem(VAULT_LOCKED_DISMISS_UNTIL_KEY, 0); + } + + setIsGloballyEnabled(newGloballyEnabled); + }; + return (
@@ -124,6 +152,23 @@ const AuthSettings: React.FC = () => { )} + {/* Autofill Popup Settings Section */} +
+
+

Autofill popup

+ +
+
+
Version: {AppInfo.VERSION}
diff --git a/browser-extension/src/entrypoints/popup/pages/Settings.tsx b/browser-extension/src/entrypoints/popup/pages/Settings.tsx index 737a0beff..1b730d2f7 100644 --- a/browser-extension/src/entrypoints/popup/pages/Settings.tsx +++ b/browser-extension/src/entrypoints/popup/pages/Settings.tsx @@ -144,11 +144,11 @@ const Settings: React.FC = () => { onClick={toggleGlobalPopup} className={`px-4 py-2 rounded-md transition-colors ${ settings.isGloballyEnabled - ? 'bg-red-500 hover:bg-red-600 text-white' - : 'bg-green-500 hover:bg-green-600 text-white' + ? 'bg-green-500 hover:bg-green-600 text-white' + : 'bg-red-500 hover:bg-red-600 text-white' }`} > - {settings.isGloballyEnabled ? 'Disable' : 'Enable'} + {settings.isGloballyEnabled ? 'Enabled' : 'Disabled'}
@@ -164,18 +164,18 @@ const Settings: React.FC = () => {

Open popup on: {settings.currentUrl}

- {settings.isEnabled ? 'Popup is active' : 'Popup is disabled'} + {settings.isEnabled ? 'Enabled for this site' : 'Disabled for this site'}

diff --git a/browser-extension/src/entrypoints/popup/pages/Unlock.tsx b/browser-extension/src/entrypoints/popup/pages/Unlock.tsx index 957590902..e6a557c3b 100644 --- a/browser-extension/src/entrypoints/popup/pages/Unlock.tsx +++ b/browser-extension/src/entrypoints/popup/pages/Unlock.tsx @@ -9,6 +9,8 @@ import SrpUtility from '../utils/SrpUtility'; import { VaultResponse } from '../../../utils/types/webapi/VaultResponse'; import { useLoading } from '../context/LoadingContext'; import { useNavigate } from 'react-router-dom'; +import { VAULT_LOCKED_DISMISS_UNTIL_KEY } from '@/entrypoints/contentScript/Popup'; +import { storage } from 'wxt/storage'; /** * Unlock page @@ -75,6 +77,9 @@ const Unlock: React.FC = () => { // Initialize the SQLite context with the new vault data. await dbContext.initializeDatabase(vaultResponseJson, passwordHashBase64); + + // Clear dismiss until (which can be enabled after user has dimissed vault is locked popup) to ensure popup is shown. + await storage.setItem(VAULT_LOCKED_DISMISS_UNTIL_KEY, 0); } catch (err) { setError('Failed to unlock vault. Please check your password and try again.'); console.error('Unlock error:', err);