Merge branch 'main' into 620-add-browser-extension-download-links-to-client

This commit is contained in:
Leendert de Borst
2025-02-27 22:22:01 +01:00
committed by GitHub
16 changed files with 298 additions and 73 deletions

View File

@@ -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: <EmailsList />, showBackButton: false },
{ path: '/emails/:id', element: <EmailDetails />, showBackButton: true, title: 'Email details' },
{ path: '/settings', element: <Settings />, showBackButton: false },
{ path: '/logout', element: <Logout />, showBackButton: false },
];
useEffect(() => {

View File

@@ -47,10 +47,7 @@ export const UserMenu: React.FC = () => {
*/
const onLogout = async () : Promise<void> => {
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();
};

View File

@@ -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;

View File

@@ -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]);

View File

@@ -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.

View File

@@ -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<void> => {
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;

View File

@@ -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<void> => {
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 (
<div className="max-w-md">
<form onSubmit={handleSubmit} className="bg-white dark:bg-gray-700 w-full shadow-md rounded px-8 pt-6 pb-8 mb-4">
@@ -136,7 +116,7 @@ const Unlock: React.FC = () => {
</Button>
<div className="text-sm font-medium text-gray-500 dark:text-gray-200 mt-6">
Switch accounts? <a href="#" onClick={handleLogout} className="text-primary-700 hover:underline dark:text-primary-500">Log out</a>
Switch accounts? <a href="/logout" className="text-primary-700 hover:underline dark:text-primary-500">Log out</a>
</div>
</form>
</div>

View File

@@ -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;

View File

@@ -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<void> => {
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<ArrayBufferLike>);
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<string |
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999999999;
z-index: 999999995;
display: flex;
align-items: center;
justify-content: center;
@@ -1042,23 +1075,33 @@ export function openAutofillPopup(input: HTMLInputElement) : void {
});
}
/**
* Convert various binary data formats to Uint8Array
*/
function toUint8Array(buffer: Uint8Array | number[] | {[key: number]: number}): Uint8Array {
if (buffer instanceof Uint8Array) {
return buffer;
}
if (Array.isArray(buffer)) {
return new Uint8Array(buffer);
}
const length = Object.keys(buffer).length;
const arr = new Uint8Array(length);
for (let i = 0; i < length; i++) {
arr[i] = buffer[i];
}
return arr;
}
/**
* Base64 encode binary data.
*/
function base64Encode(buffer: Uint8Array | number[] | {[key: number]: number}): string | null {
try {
// Handle object with numeric keys
if (typeof buffer === 'object' && !Array.isArray(buffer) && !(buffer instanceof Uint8Array)) {
const length = Object.keys(buffer).length;
const arr = new Uint8Array(length);
for (let i = 0; i < length; i++) {
arr[i] = buffer[i];
}
buffer = arr;
}
// Convert to array if Uint8Array
const arr = Array.from(buffer as Uint8Array | number[]);
const arr = Array.from(toUint8Array(buffer));
return btoa(arr.reduce((data, byte) => data + String.fromCharCode(byte), ''));
} catch (error) {
console.error('Error encoding to base64:', error);
@@ -1115,3 +1158,42 @@ async function getFaviconBytes(document: Document): Promise<Uint8Array | null> {
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('<?xml') || header.includes('<svg');
};
/**
* Check if the file is an ICO file.
*/
const isIco = () : boolean => {
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';
}

View File

@@ -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<Response>(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<void> {
const refreshToken = await this.getRefreshToken();
if (!refreshToken) {
return;
public async logout(statusError: string | null = null): Promise<void> {
// 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);

View File

@@ -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 {

View File

@@ -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';

View File

@@ -0,0 +1,6 @@
<html lang="en"><head><meta name="referrer" content="origin"><meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="icon" href="y18.svg"><style>com-strongbox-extension {visibility: visible !important; /* Ensure visibility for Strongbox Extension */}</style><style>com-strongbox-extension {visibility: visible !important; /* Ensure visibility for Strongbox Extension */}</style><style>com-strongbox-extension {visibility: visible !important; /* Ensure visibility for Strongbox Extension */}</style></head><body><b>Login</b><br><br>
<form action="login" method="post"><input type="hidden" name="goto" value="news"><table border="0"><tbody><tr><td>username:</td><td><input type="text" name="acct" size="20" autocorrect="off" spellcheck="false" autocapitalize="off" autofocus="true" id="aliasvault-input-xahcz4tlf" autocomplete="false"></td></tr><tr><td>password:</td><td><input type="password" name="pw" size="20"></td></tr></tbody></table><br>
<input type="submit" value="login"></form><a href="forgot">Forgot your password?</a><br><br>
<b>Create Account</b><br><br>
<form action="login" method="post"><input type="hidden" name="goto" value="news"><input type="hidden" name="creating" value="t"><table border="0"><tbody><tr><td>username:</td><td><input type="text" name="acct" size="20" autocorrect="off" spellcheck="false" autocapitalize="off" id="aliasvault-input-7owmnahd9" autocomplete="false"></td></tr><tr><td>password:</td><td><input type="password" name="pw" size="20" id="aliasvault-input-ienw3qgxv" autocomplete="false"></td></tr></tbody></table><br>
<input type="submit" value="create account"></form></body></html>

View File

@@ -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 |

View File

@@ -26,6 +26,9 @@ else
[Parameter]
public bool Padding { get; set; }
/// <summary>
/// The data URL of the favicon.
/// </summary>
private string? _faviconDataUrl;
/// <inheritdoc />
@@ -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}";
}
}
/// <summary>
/// Detect the mime type of the favicon.
/// </summary>
/// <param name="bytes">The bytes of the favicon.</param>
/// <returns>The mime type of the favicon.</returns>
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("<?xml") || header.Contains("<svg"))
{
return "image/svg+xml";
}
}
// Check for ICO.
if (bytes.Length >= 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";
}
}

View File

@@ -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;
}