diff --git a/browser-extensions/chrome/src/app/App.tsx b/browser-extensions/chrome/src/app/App.tsx index ca682ab0f..3fa389cf2 100644 --- a/browser-extensions/chrome/src/app/App.tsx +++ b/browser-extensions/chrome/src/app/App.tsx @@ -9,12 +9,13 @@ import CredentialsList from './pages/CredentialsList'; import EmailsList from './pages/EmailsList'; import LoadingSpinner from './components/LoadingSpinner'; import Home from './pages/Home'; -import './style.css'; import CredentialDetails from './pages/CredentialDetails'; import EmailDetails from './pages/EmailDetails'; import Settings from './pages/Settings'; import GlobalStateChangeHandler from './components/GlobalStateChangeHandler'; import { useLoading } from './context/LoadingContext'; +import Logout from './pages/Logout'; +import './style.css'; /** * Route configuration. @@ -44,6 +45,7 @@ const App: React.FC = () => { { path: '/emails', element: , showBackButton: false }, { path: '/emails/:id', element: , showBackButton: true, title: 'Email details' }, { path: '/settings', element: , showBackButton: false }, + { path: '/logout', element: , showBackButton: false }, ]; useEffect(() => { diff --git a/browser-extensions/chrome/src/app/components/Layout/UserMenu.tsx b/browser-extensions/chrome/src/app/components/Layout/UserMenu.tsx index d14752c62..f171ba087 100644 --- a/browser-extensions/chrome/src/app/components/Layout/UserMenu.tsx +++ b/browser-extensions/chrome/src/app/components/Layout/UserMenu.tsx @@ -47,10 +47,7 @@ export const UserMenu: React.FC = () => { */ const onLogout = async () : Promise => { showLoading(); - await authContext.logout(); - navigate('/', { replace: true }); - // Delay for 100ms for improved UX - await new Promise(resolve => setTimeout(resolve, 100)); + navigate('/logout', { replace: true }); hideLoading(); }; diff --git a/browser-extensions/chrome/src/app/context/DbContext.tsx b/browser-extensions/chrome/src/app/context/DbContext.tsx index 81170725d..72a8f6a83 100644 --- a/browser-extensions/chrome/src/app/context/DbContext.tsx +++ b/browser-extensions/chrome/src/app/context/DbContext.tsx @@ -2,6 +2,7 @@ import React, { createContext, useContext, useState, useEffect, useCallback, use import SqliteClient from '../../shared/SqliteClient'; import { VaultResponse } from '../../shared/types/webapi/VaultResponse'; import EncryptionUtility from '../../shared/EncryptionUtility'; + type DbContextType = { sqliteClient: SqliteClient | null; dbInitialized: boolean; diff --git a/browser-extensions/chrome/src/app/context/WebApiContext.tsx b/browser-extensions/chrome/src/app/context/WebApiContext.tsx index 21e813f9c..65f7b6e92 100644 --- a/browser-extensions/chrome/src/app/context/WebApiContext.tsx +++ b/browser-extensions/chrome/src/app/context/WebApiContext.tsx @@ -16,7 +16,13 @@ export const WebApiProvider: React.FC<{ children: React.ReactNode }> = ({ childr */ useEffect(() : void => { const service = new WebApiService( - logout + (statusError: string | null) => { + if (statusError) { + logout(statusError); + } else { + logout(); + } + } ); setWebApiService(service); }, [logout]); diff --git a/browser-extensions/chrome/src/app/pages/CredentialsList.tsx b/browser-extensions/chrome/src/app/pages/CredentialsList.tsx index 00f43bbf0..56549ce86 100644 --- a/browser-extensions/chrome/src/app/pages/CredentialsList.tsx +++ b/browser-extensions/chrome/src/app/pages/CredentialsList.tsx @@ -7,7 +7,6 @@ import { useLoading } from '../context/LoadingContext'; import { useWebApi } from '../context/WebApiContext'; import { VaultResponse } from '../../shared/types/webapi/VaultResponse'; import ReloadButton from '../components/ReloadButton'; -import { useAuth } from '../context/AuthContext'; import LoadingSpinner from '../components/LoadingSpinner'; import { useMinDurationLoading } from '../hooks/useMinDurationLoading'; @@ -21,7 +20,6 @@ const CredentialsList: React.FC = () => { const [searchTerm, setSearchTerm] = useState(''); const navigate = useNavigate(); const { showLoading, hideLoading, setIsInitialLoading } = useLoading(); - const authContext = useAuth(); /** * Loading state with minimum duration for more fluid UX. @@ -40,7 +38,7 @@ const CredentialsList: React.FC = () => { const statusResponse = await webApi.getStatus(); const statusError = webApi.validateStatusResponse(statusResponse); if (statusError !== null) { - authContext.logout(statusError); + await webApi.logout(statusError); return; } @@ -60,7 +58,7 @@ const CredentialsList: React.FC = () => { const vaultError = webApi.validateVaultResponse(vaultResponseJson); if (vaultError) { - authContext.logout(vaultError); + await webApi.logout(vaultError); hideLoading(); return; } @@ -73,7 +71,7 @@ const CredentialsList: React.FC = () => { } catch (err) { console.error('Refresh error:', err); } - }, [dbContext, webApi, authContext, hideLoading]); + }, [dbContext, webApi, hideLoading]); /** * Manually refresh the credentials list. diff --git a/browser-extensions/chrome/src/app/pages/Logout.tsx b/browser-extensions/chrome/src/app/pages/Logout.tsx new file mode 100644 index 000000000..9a2c253f8 --- /dev/null +++ b/browser-extensions/chrome/src/app/pages/Logout.tsx @@ -0,0 +1,32 @@ +import React, { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; +import { useWebApi } from '../context/WebApiContext'; + +/** + * Logout page. + */ +const Logout: React.FC = () => { + const authContext = useAuth(); + const webApi = useWebApi(); + const navigate = useNavigate(); + /** + * Logout and navigate to home page. + */ + useEffect(() => { + /** + * Perform logout via async method to ensure logout is completed before navigating to home page. + */ + const performLogout = async () : Promise => { + await webApi.logout(); + navigate('/'); + }; + + performLogout(); + }, [authContext, navigate, webApi]); + + // Return null since this is just a functional component that handles logout. + return null; +}; + +export default Logout; diff --git a/browser-extensions/chrome/src/app/pages/Unlock.tsx b/browser-extensions/chrome/src/app/pages/Unlock.tsx index 1d1972d11..5799fe5c4 100644 --- a/browser-extensions/chrome/src/app/pages/Unlock.tsx +++ b/browser-extensions/chrome/src/app/pages/Unlock.tsx @@ -31,7 +31,7 @@ const Unlock: React.FC = () => { const statusResponse = await webApi.getStatus(); const statusError = webApi.validateStatusResponse(statusResponse); if (statusError !== null) { - authContext.logout(statusError); + await webApi.logout(statusError); } }; @@ -81,26 +81,6 @@ const Unlock: React.FC = () => { } }; - /** - * Handle logout - */ - const handleLogout = async () : Promise => { - showLoading(); - try { - await webApi.logout(); - } catch (err) { - console.error('Logout error:', err); - } - - try { - await authContext.logout(); - } catch (err) { - console.error('Logout error:', err); - } finally { - hideLoading(); - } - }; - return (
@@ -136,7 +116,7 @@ const Unlock: React.FC = () => {
- Switch accounts? Log out + Switch accounts? Log out
diff --git a/browser-extensions/chrome/src/contentScript/Form.ts b/browser-extensions/chrome/src/contentScript/Form.ts index 6246af10b..af07fb92d 100644 --- a/browser-extensions/chrome/src/contentScript/Form.ts +++ b/browser-extensions/chrome/src/contentScript/Form.ts @@ -354,7 +354,7 @@ function triggerInputEvents(element: HTMLInputElement | HTMLSelectElement) : voi const rect = element.getBoundingClientRect(); overlay.style.cssText = ` position: fixed; - z-index: 999999999; + z-index: 999999991; pointer-events: none; top: ${rect.top}px; left: ${rect.left}px; diff --git a/browser-extensions/chrome/src/contentScript/Popup.ts b/browser-extensions/chrome/src/contentScript/Popup.ts index 321e0f251..a7cc8a843 100644 --- a/browser-extensions/chrome/src/contentScript/Popup.ts +++ b/browser-extensions/chrome/src/contentScript/Popup.ts @@ -34,7 +34,7 @@ export function createBasePopup(input: HTMLInputElement) : HTMLElement { popup.style.cssText = ` all: unset; position: absolute; - z-index: 999999999; + z-index: 999999991; background: ${isDarkMode() ? '#1f2937' : 'white'}; border: 1px solid ${isDarkMode() ? '#374151' : '#ccc'}; border-radius: 4px; @@ -228,7 +228,14 @@ export function createAutofillPopup(input: HTMLInputElement, credentials: Creden createButton.style.backgroundColor = isDarkMode() ? '#374151' : '#f3f4f6'; }); - createButton.addEventListener('click', async () => { + /** + * Handle create button click + */ + const handleCreateClick = async (e: Event) : Promise => { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + // Determine service name based on conditions let suggestedName = document.title; @@ -331,7 +338,7 @@ export function createAutofillPopup(input: HTMLInputElement, credentials: Creden }; chrome.runtime.sendMessage({ type: 'CREATE_IDENTITY', credential }, () => { - // Refresh the popup to show new identity + // Refresh the popup to show new identity. openAutofillPopup(input); }); } catch (error) { @@ -345,9 +352,32 @@ export function createAutofillPopup(input: HTMLInputElement, credentials: Creden removeExistingPopup(); }, 2000); } + }; + + // Add click listener with capture and prevent removal. + createButton.addEventListener('click', handleCreateClick, { + capture: true, + passive: false }); - // Create search input instead of button + // Backup click handling using mousedown/mouseup if needed. + let isMouseDown = false; + createButton.addEventListener('mousedown', (e) => { + e.preventDefault(); + e.stopPropagation(); + isMouseDown = true; + }, { capture: true }); + + createButton.addEventListener('mouseup', (e) => { + e.preventDefault(); + e.stopPropagation(); + if (isMouseDown) { + handleCreateClick(e); + } + isMouseDown = false; + }, { capture: true }); + + // Create search input. const searchInput = document.createElement('input'); searchInput.type = 'text'; searchInput.dataset.aliasvaultIgnore = 'true'; @@ -370,7 +400,7 @@ export function createAutofillPopup(input: HTMLInputElement, credentials: Creden text-align: left; `; - // Add focus styles + // Add focus styles. searchInput.addEventListener('focus', () => { searchInput.style.borderColor = '#2563eb'; searchInput.style.boxShadow = '0 0 0 2px rgba(37, 99, 235, 0.2)'; @@ -381,7 +411,7 @@ export function createAutofillPopup(input: HTMLInputElement, credentials: Creden searchInput.style.boxShadow = 'none'; }); - // Handle search input + // Handle search input. let searchTimeout: NodeJS.Timeout; searchInput.addEventListener('input', () => { @@ -654,8 +684,11 @@ function createCredentialList(credentials: Credential[], input: HTMLInputElement // Handle base64 image data if (cred.Logo) { try { - const base64Logo = base64Encode(cred.Logo as Uint8Array); - imgElement.src = `data:image/x-icon;base64,${base64Logo}`; + const logoBytes = toUint8Array(cred.Logo); + const base64Logo = base64Encode(logoBytes); + // Detect image type from first few bytes + const mimeType = detectMimeType(logoBytes); + imgElement.src = `data:${mimeType};base64,${base64Logo}`; } catch (error) { console.error('Error setting logo:', error); imgElement.src = `data:image/x-icon;base64,${placeholderBase64}`; @@ -837,7 +870,7 @@ export async function createEditNamePopup(defaultName: string): Promise data + String.fromCharCode(byte), '')); } catch (error) { console.error('Error encoding to base64:', error); @@ -1115,3 +1158,42 @@ async function getFaviconBytes(document: Document): Promise { return null; // Return null if no favicon could be downloaded } + +/** + * Detect MIME type from file signature (magic numbers) + */ +function detectMimeType(bytes: Uint8Array): string { + /** + * Check if the file is an SVG file. + */ + const isSvg = () : boolean => { + const header = new TextDecoder().decode(bytes.slice(0, 5)).toLowerCase(); + return header.includes(' { + return bytes[0] === 0x00 && bytes[1] === 0x00 && bytes[2] === 0x01 && bytes[3] === 0x00; + }; + + /** + * Check if the file is an PNG file. + */ + const isPng = () : boolean => { + return bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47; + }; + + if (isSvg()) { + return 'image/svg+xml'; + } + if (isIco()) { + return 'image/x-icon'; + } + if (isPng()) { + return 'image/png'; + } + + return 'image/x-icon'; +} diff --git a/browser-extensions/chrome/src/shared/WebApiService.ts b/browser-extensions/chrome/src/shared/WebApiService.ts index c0af80d88..6b1f667b5 100644 --- a/browser-extensions/chrome/src/shared/WebApiService.ts +++ b/browser-extensions/chrome/src/shared/WebApiService.ts @@ -19,9 +19,9 @@ export class WebApiService { /** * Constructor for the WebApiService class. * - * @param {Function} handleLogout - Function to handle logout. + * @param {Function} authContextLogout - Function to handle logout. */ - public constructor(private readonly handleLogout: () => void) { } + public constructor(private readonly authContextLogout: (statusError: string | null) => void) { } /** * Get the base URL for the API from settings. @@ -79,7 +79,7 @@ export class WebApiService { return parseJson ? retryResponse.json() : retryResponse as unknown as T; } else { - this.handleLogout(); + this.authContextLogout(null); throw new Error('Session expired'); } } @@ -106,11 +106,13 @@ export class WebApiService { try { const baseUrl = await this.getBaseUrl(); + const response = await fetch(`${baseUrl}Auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Ignore-Failure': 'true', + 'X-AliasVault-Client': `${AppInfo.CLIENT_NAME}-${AppInfo.VERSION}`, }, body: JSON.stringify({ token: await this.getAccessToken(), @@ -126,7 +128,7 @@ export class WebApiService { this.updateTokens(tokenResponse.token, tokenResponse.refreshToken); return tokenResponse.token; } catch { - this.handleLogout(); + this.authContextLogout('Your session has expired. Please login again.'); return null; } } @@ -146,7 +148,7 @@ export class WebApiService { const response = await this.fetch(endpoint, { method: 'GET', headers: { - 'Accept': 'application/octet-stream' + 'Accept': 'application/octet-stream', } }, false); @@ -197,18 +199,26 @@ export class WebApiService { } /** - * Logout and revoke tokens via WebApi. + * Logout and revoke tokens via WebApi and remove local storage tokens via AuthContext. */ - public async logout(): Promise { - const refreshToken = await this.getRefreshToken(); - if (!refreshToken) { - return; + public async logout(statusError: string | null = null): Promise { + // Logout and revoke tokens via WebApi. + try { + const refreshToken = await this.getRefreshToken(); + if (!refreshToken) { + return; + } + + await this.post('Auth/revoke', { + token: await this.getAccessToken(), + refreshToken: refreshToken, + }, false); + } catch (err) { + console.error('WebApi logout error:', err); } - await this.post('Auth/revoke', { - token: await this.getAccessToken(), - refreshToken: refreshToken, - }, false); + // Logout and remove tokens from local storage via AuthContext. + this.authContextLogout(statusError); } /** @@ -308,7 +318,7 @@ export class WebApiService { /** * When the reader has finished loading, convert the result to a Base64 string. */ - reader.onloadend = () : void => { + reader.onloadend = (): void => { const result = reader.result; if (typeof result === 'string') { resolve(result.split(',')[1]); // Remove the data URL prefix @@ -320,7 +330,7 @@ export class WebApiService { /** * If the reader encounters an error, reject the promise with a proper Error object. */ - reader.onerror = () : void => { + reader.onerror = (): void => { reject(new Error('Failed to read blob as Data URL')); }; reader.readAsDataURL(blob); diff --git a/browser-extensions/chrome/src/shared/formDetector/FormDetector.ts b/browser-extensions/chrome/src/shared/formDetector/FormDetector.ts index 79fa12ffe..946cb12a3 100644 --- a/browser-extensions/chrome/src/shared/formDetector/FormDetector.ts +++ b/browser-extensions/chrome/src/shared/formDetector/FormDetector.ts @@ -101,15 +101,33 @@ export class FormDetector { } } - // Check for parent label + // Check for parent label and table cell structure let currentElement = input; for (let i = 0; i < 3; i++) { + // Check for parent label const parentLabel = currentElement.closest('label'); if (parentLabel) { attributes.push(parentLabel.textContent?.toLowerCase() ?? ''); break; } + // Check for table cell structure + const parentTd = currentElement.closest('td'); + if (parentTd) { + // Get the parent row + const parentTr = parentTd.closest('tr'); + if (parentTr) { + // Check all sibling cells in the row + const siblingTds = parentTr.querySelectorAll('td'); + for (const td of siblingTds) { + if (td !== parentTd) { // Skip the cell containing the input + attributes.push(td.textContent?.toLowerCase() ?? ''); + } + } + } + break; // Found table structure, no need to continue up the tree + } + if (currentElement.parentElement) { currentElement = currentElement.parentElement as HTMLInputElement; } else { diff --git a/browser-extensions/chrome/src/shared/formDetector/__tests__/FormDetector.en.test.ts b/browser-extensions/chrome/src/shared/formDetector/__tests__/FormDetector.en.test.ts index c694de033..a90656b4e 100644 --- a/browser-extensions/chrome/src/shared/formDetector/__tests__/FormDetector.en.test.ts +++ b/browser-extensions/chrome/src/shared/formDetector/__tests__/FormDetector.en.test.ts @@ -44,6 +44,13 @@ describe('FormDetector English tests', () => { testField(FormField.LastName, 'fbclc_lName', htmlFile); }); + describe('English registration form 5 detection', () => { + const htmlFile = 'en-registration-form5.html'; + + testField(FormField.Username, 'aliasvault-input-7owmnahd9', htmlFile); + testField(FormField.Password, 'aliasvault-input-ienw3qgxv', htmlFile); + }); + describe('English email form 1 detection', () => { const htmlFile = 'en-email-form1.html'; diff --git a/browser-extensions/chrome/src/shared/formDetector/__tests__/test-forms/en-registration-form5.html b/browser-extensions/chrome/src/shared/formDetector/__tests__/test-forms/en-registration-form5.html new file mode 100644 index 000000000..2e52e3cfd --- /dev/null +++ b/browser-extensions/chrome/src/shared/formDetector/__tests__/test-forms/en-registration-form5.html @@ -0,0 +1,6 @@ +Login

+
username:
password:

+
Forgot your password?

+ Create Account

+
username:
password:

+
\ No newline at end of file diff --git a/docs/misc/dev/browser-extensions.md b/docs/misc/dev/browser-extensions.md index 7aa720a1f..0a96d2644 100644 --- a/docs/misc/dev/browser-extensions.md +++ b/docs/misc/dev/browser-extensions.md @@ -56,3 +56,5 @@ The following websites have been known to cause issues in the past (but should b | --- | --- | | https://www.paprika-shopping.nl/nieuwsbrief/newsletter-register-landing.html | Popup CSS style conflicts | | https://bloshing.com/inschrijven-nieuwsbrief | Popup CSS style conflicts | +| https://gamefaqs.gamespot.com/user | Popup buttons not working | +| https://news.ycombinator.com/login?goto=news | Popup and client favicon not showing due to SVG format | diff --git a/src/AliasVault.Client/Main/Components/Credentials/DisplayFavicon.razor b/src/AliasVault.Client/Main/Components/Credentials/DisplayFavicon.razor index a3186fc28..80959d9ee 100644 --- a/src/AliasVault.Client/Main/Components/Credentials/DisplayFavicon.razor +++ b/src/AliasVault.Client/Main/Components/Credentials/DisplayFavicon.razor @@ -26,6 +26,9 @@ else [Parameter] public bool Padding { get; set; } + /// + /// The data URL of the favicon. + /// private string? _faviconDataUrl; /// @@ -33,8 +36,46 @@ else { if (FaviconBytes is not null) { + string mimeType = DetectMimeType(FaviconBytes); string base64String = Convert.ToBase64String(FaviconBytes); - _faviconDataUrl = $"data:image/x-icon;base64,{base64String}"; + _faviconDataUrl = $"data:{mimeType};base64,{base64String}"; } } + + /// + /// Detect the mime type of the favicon. + /// + /// The bytes of the favicon. + /// The mime type of the favicon. + private static string DetectMimeType(byte[] bytes) + { + // Check for SVG. + if (bytes.Length >= 5) + { + string header = System.Text.Encoding.ASCII.GetString(bytes.Take(5).ToArray()).ToLower(); + if (header.Contains("= 4 && + bytes[0] == 0x00 && bytes[1] == 0x00 && + bytes[2] == 0x01 && bytes[3] == 0x00) + { + return "image/x-icon"; + } + + // Check for PNG. + if (bytes.Length >= 4 && + bytes[0] == 0x89 && bytes[1] == 0x50 && + bytes[2] == 0x4E && bytes[3] == 0x47) + { + return "image/png"; + } + + // Default to x-icon if unknown. + return "image/x-icon"; + } } diff --git a/src/AliasVault.Client/wwwroot/css/tailwind.css b/src/AliasVault.Client/wwwroot/css/tailwind.css index f175c3c1a..711b704a5 100644 --- a/src/AliasVault.Client/wwwroot/css/tailwind.css +++ b/src/AliasVault.Client/wwwroot/css/tailwind.css @@ -1342,6 +1342,11 @@ video { border-color: rgb(191 219 254 / var(--tw-border-opacity)); } +.border-blue-300 { + --tw-border-opacity: 1; + border-color: rgb(147 197 253 / var(--tw-border-opacity)); +} + .border-blue-700 { --tw-border-opacity: 1; border-color: rgb(29 78 216 / var(--tw-border-opacity)); @@ -1392,6 +1397,16 @@ video { border-color: rgb(239 68 68 / var(--tw-border-opacity)); } +.border-blue-200 { + --tw-border-opacity: 1; + border-color: rgb(191 219 254 / var(--tw-border-opacity)); +} + +.border-gray-100 { + --tw-border-opacity: 1; + border-color: rgb(243 244 246 / var(--tw-border-opacity)); +} + .bg-amber-50 { --tw-bg-opacity: 1; background-color: rgb(255 251 235 / var(--tw-bg-opacity)); @@ -1729,6 +1744,10 @@ video { vertical-align: middle; } +.align-text-bottom { + vertical-align: text-bottom; +} + .font-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace; } @@ -1942,6 +1961,11 @@ video { color: rgb(133 77 14 / var(--tw-text-opacity)); } +.text-primary-800 { + --tw-text-opacity: 1; + color: rgb(154 93 38 / var(--tw-text-opacity)); +} + .opacity-0 { opacity: 0; } @@ -2322,6 +2346,11 @@ video { border-color: rgb(59 130 246 / var(--tw-border-opacity)); } +.dark\:border-blue-800:is(.dark *) { + --tw-border-opacity: 1; + border-color: rgb(30 64 175 / var(--tw-border-opacity)); +} + .dark\:border-gray-400:is(.dark *) { --tw-border-opacity: 1; border-color: rgb(156 163 175 / var(--tw-border-opacity)); @@ -2381,6 +2410,11 @@ video { background-color: rgb(30 58 138 / 0.2); } +.dark\:bg-blue-900\/50:is(.dark *) { + background-color: rgb(30 58 138 / 0.5); + +} + .dark\:bg-gray-500:is(.dark *) { --tw-bg-opacity: 1; background-color: rgb(107 114 128 / var(--tw-bg-opacity)); @@ -2465,6 +2499,15 @@ video { background-color: rgb(113 63 18 / var(--tw-bg-opacity)); } +.dark\:bg-blue-900\/20:is(.dark *) { + background-color: rgb(30 58 138 / 0.2); +} + +.dark\:bg-primary-900:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(123 74 30 / var(--tw-bg-opacity)); +} + .dark\:bg-opacity-80:is(.dark *) { --tw-bg-opacity: 0.8; }