From c688764831b50539e4bcfe2dfe1cc19a1eb76a1b Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 9 Jun 2025 20:26:14 +0200 Subject: [PATCH] Add credential add page (#900) --- .../background/VaultMessageHandler.ts | 40 +----- .../src/entrypoints/popup/App.tsx | 5 +- .../popup/components/Layout/Header.tsx | 26 ---- .../popup/components/icons/HeaderIcons.tsx | 14 +- ...edentialEdit.tsx => CredentialAddEdit.tsx} | 124 +++++++++++++++--- .../popup/pages/CredentialDetails.tsx | 2 +- .../popup/pages/CredentialsList.tsx | 35 ++++- .../src/entrypoints/popup/pages/Settings.tsx | 33 +++++ .../src/utils/SqliteClient.ts | 42 +++++- 9 files changed, 226 insertions(+), 95 deletions(-) rename apps/browser-extension/src/entrypoints/popup/pages/{CredentialEdit.tsx => CredentialAddEdit.tsx} (69%) diff --git a/apps/browser-extension/src/entrypoints/background/VaultMessageHandler.ts b/apps/browser-extension/src/entrypoints/background/VaultMessageHandler.ts index 4051fdc94..57cca153b 100644 --- a/apps/browser-extension/src/entrypoints/background/VaultMessageHandler.ts +++ b/apps/browser-extension/src/entrypoints/background/VaultMessageHandler.ts @@ -226,48 +226,16 @@ export async function getEmailAddressesForVault( /** * Get default email domain for a vault. */ -export function handleGetDefaultEmailDomain( -) : Promise { - return (async () : Promise => { +export function handleGetDefaultEmailDomain(): Promise { + return (async (): Promise => { try { const privateEmailDomains = await storage.getItem('session:privateEmailDomains') as string[]; const publicEmailDomains = await storage.getItem('session:publicEmailDomains') as string[]; const sqliteClient = await createVaultSqliteClient(); - const defaultEmailDomain = sqliteClient.getDefaultEmailDomain(); + const defaultEmailDomain = sqliteClient.getDefaultEmailDomain(privateEmailDomains, publicEmailDomains); - /** - * Check if a domain is valid. - */ - const isValidDomain = (domain: string) : boolean => { - const isValid = (domain && - domain !== 'DISABLED.TLD' && - (privateEmailDomains.includes(domain) || publicEmailDomains.includes(domain))) as boolean; - - return isValid; - }; - - // First check if the default domain that is configured in the vault is still valid. - if (defaultEmailDomain && isValidDomain(defaultEmailDomain)) { - return { success: true, value: defaultEmailDomain }; - } - - // If default domain is not valid, fall back to first available private domain. - const firstPrivate = privateEmailDomains.find(isValidDomain); - - if (firstPrivate) { - return { success: true, value: firstPrivate }; - } - - // Return first valid public domain if no private domains are available. - const firstPublic = publicEmailDomains.find(isValidDomain); - - if (firstPublic) { - return { success: true, value: firstPublic }; - } - - // Return null if no valid domains are found - return { success: true }; + return { success: true, value: defaultEmailDomain ?? undefined }; } catch (error) { console.error('Error getting default email domain:', error); return { success: false, error: 'Failed to get default email domain' }; diff --git a/apps/browser-extension/src/entrypoints/popup/App.tsx b/apps/browser-extension/src/entrypoints/popup/App.tsx index c2263e1ef..de5b7f924 100644 --- a/apps/browser-extension/src/entrypoints/popup/App.tsx +++ b/apps/browser-extension/src/entrypoints/popup/App.tsx @@ -9,8 +9,8 @@ import { useAuth } from '@/entrypoints/popup/context/AuthContext'; import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext'; import { useLoading } from '@/entrypoints/popup/context/LoadingContext'; import AuthSettings from '@/entrypoints/popup/pages/AuthSettings'; +import CredentialAddEdit from '@/entrypoints/popup/pages/CredentialAddEdit'; import CredentialDetails from '@/entrypoints/popup/pages/CredentialDetails'; -import CredentialEdit from '@/entrypoints/popup/pages/CredentialEdit'; import CredentialsList from '@/entrypoints/popup/pages/CredentialsList'; import EmailDetails from '@/entrypoints/popup/pages/EmailDetails'; import EmailsList from '@/entrypoints/popup/pages/EmailsList'; @@ -46,8 +46,9 @@ const App: React.FC = () => { { path: '/', element: , showBackButton: false }, { path: '/auth-settings', element: , showBackButton: true, title: 'Settings' }, { path: '/credentials', element: , showBackButton: false }, + { path: '/credentials/add', element: , showBackButton: true, title: 'Add credential' }, { path: '/credentials/:id', element: , showBackButton: true, title: 'Credential details' }, - { path: '/credentials/:id/edit', element: , showBackButton: true, title: 'Edit credential' }, + { path: '/credentials/:id/edit', element: , showBackButton: true, title: 'Edit credential' }, { path: '/emails', element: , showBackButton: false }, { path: '/emails/:id', element: , showBackButton: true, title: 'Email details' }, { path: '/settings', element: , showBackButton: false }, diff --git a/apps/browser-extension/src/entrypoints/popup/components/Layout/Header.tsx b/apps/browser-extension/src/entrypoints/popup/components/Layout/Header.tsx index 89d8b527f..82a827f9e 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/Layout/Header.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/Layout/Header.tsx @@ -1,14 +1,8 @@ import React from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; -import HeaderButton from '@/entrypoints/popup/components/HeaderButton'; -import { HeaderIconType } from '@/entrypoints/popup/components/icons/HeaderIcons'; import { useAuth } from '@/entrypoints/popup/context/AuthContext'; -import { AppInfo } from '@/utils/AppInfo'; - -import { storage } from '#imports'; - /** * Header props. */ @@ -32,19 +26,6 @@ const Header: React.FC = ({ const navigate = useNavigate(); const location = useLocation(); - /** - * Open the client tab. - */ - const openClientTab = async () : Promise => { - const settingClientUrl = await storage.getItem('local:clientUrl') as string; - let clientUrl = AppInfo.DEFAULT_CLIENT_URL; - if (settingClientUrl && settingClientUrl.length > 0) { - clientUrl = settingClientUrl; - } - - window.open(clientUrl, '_blank'); - }; - // Updated route matching logic to handle URL parameters const currentRoute = routes?.find(route => { // Convert route pattern to regex @@ -112,13 +93,6 @@ const Header: React.FC = ({
- {!currentRoute?.showBackButton && ( - - )} {!authContext.isLoggedIn ? ( setValue('Alias.Email', value)} error={errors.Alias?.Email?.message} /> @@ -294,28 +378,28 @@ const CredentialAddEdit: React.FC = () => { setValue('Alias.FirstName', value)} error={errors.Alias?.FirstName?.message} /> setValue('Alias.LastName', value)} error={errors.Alias?.LastName?.message} /> setValue('Alias.NickName', value)} error={errors.Alias?.NickName?.message} /> setValue('Alias.Gender', value)} error={errors.Alias?.Gender?.message} /> @@ -323,7 +407,7 @@ const CredentialAddEdit: React.FC = () => { id="birthDate" label="Birth Date" placeholder="YYYY-MM-DD" - value={watch('Alias.BirthDate')} + value={watch('Alias.BirthDate') ?? ''} onChange={(value) => setValue('Alias.BirthDate', value)} error={errors.Alias?.BirthDate?.message} /> @@ -336,7 +420,7 @@ const CredentialAddEdit: React.FC = () => { setValue('Notes', value)} multiline rows={4} diff --git a/apps/browser-extension/src/entrypoints/popup/pages/CredentialDetails.tsx b/apps/browser-extension/src/entrypoints/popup/pages/CredentialDetails.tsx index 70ecdfcc4..ee9526f88 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/CredentialDetails.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/CredentialDetails.tsx @@ -67,7 +67,7 @@ const CredentialDetails: React.FC = (): React.ReactElement => { window.open( `popup.html?expanded=true#/credentials/${id}/edit`, - 'CredentialEdit', + 'CredentialAddEdit', `width=${width},height=${height},left=${left},top=${top},popup=true` ); } else { diff --git a/apps/browser-extension/src/entrypoints/popup/pages/CredentialsList.tsx b/apps/browser-extension/src/entrypoints/popup/pages/CredentialsList.tsx index 4b21a7729..fca63a7f1 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/CredentialsList.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/CredentialsList.tsx @@ -1,9 +1,13 @@ import React, { useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; import CredentialCard from '@/entrypoints/popup/components/CredentialCard'; +import HeaderButton from '@/entrypoints/popup/components/HeaderButton'; +import { HeaderIconType } from '@/entrypoints/popup/components/icons/HeaderIcons'; import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner'; import ReloadButton from '@/entrypoints/popup/components/ReloadButton'; import { useDb } from '@/entrypoints/popup/context/DbContext'; +import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext'; import { useLoading } from '@/entrypoints/popup/context/LoadingContext'; import { useWebApi } from '@/entrypoints/popup/context/WebApiContext'; import { useVaultSync } from '@/entrypoints/popup/hooks/useVaultSync'; @@ -18,7 +22,9 @@ import { useMinDurationLoading } from '@/hooks/useMinDurationLoading'; const CredentialsList: React.FC = () => { const dbContext = useDb(); const webApi = useWebApi(); + const navigate = useNavigate(); const { syncVault } = useVaultSync(); + const { setHeaderButtons } = useHeaderButtons(); const [credentials, setCredentials] = useState([]); const [searchTerm, setSearchTerm] = useState(''); const { showLoading, hideLoading, setIsInitialLoading } = useLoading(); @@ -28,6 +34,13 @@ const CredentialsList: React.FC = () => { */ const [isLoading, setIsLoading] = useMinDurationLoading(true, 100); + /** + * Handle add new credential. + */ + const handleAddCredential = useCallback(() : void => { + navigate('/credentials/add'); + }, [navigate]); + /** * Retrieve latest vault and refresh the credentials list. */ @@ -43,14 +56,12 @@ const CredentialsList: React.FC = () => { * On success. */ onSuccess: async (_hasNewVault) => { - // Refresh credentials list, whether there is a new vault or not. - const results = dbContext.sqliteClient?.getAllCredentials() ?? []; - setCredentials(results); + // Credentials list is refreshed automatically when the (new) sqlite client is available via useEffect hook below. }, /** * On offline. */ - onOffline: () => { + _onOffline: () => { // Not implemented for browser extension yet. }, /** @@ -76,6 +87,22 @@ const CredentialsList: React.FC = () => { hideLoading(); }; + // Set header buttons on mount and clear on unmount + useEffect((): (() => void) => { + const headerButtonsJSX = ( +
+ +
+ ); + + setHeaderButtons(headerButtonsJSX); + return () => setHeaderButtons(null); + }, [setHeaderButtons, handleAddCredential]); + /** * Load credentials list on mount and on sqlite client change. */ diff --git a/apps/browser-extension/src/entrypoints/popup/pages/Settings.tsx b/apps/browser-extension/src/entrypoints/popup/pages/Settings.tsx index db2a51840..4e0784b19 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/Settings.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/Settings.tsx @@ -1,7 +1,10 @@ import React, { useEffect, useState, useCallback } from 'react'; import { sendMessage } from 'webext-bridge/popup'; +import HeaderButton from '@/entrypoints/popup/components/HeaderButton'; +import { HeaderIconType } from '@/entrypoints/popup/components/icons/HeaderIcons'; import { useAuth } from '@/entrypoints/popup/context/AuthContext'; +import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext'; import { useTheme } from '@/entrypoints/popup/context/ThemeContext'; import { AppInfo } from '@/utils/AppInfo'; @@ -28,6 +31,7 @@ type PopupSettings = { const Settings: React.FC = () => { const { theme, setTheme } = useTheme(); const authContext = useAuth(); + const { setHeaderButtons } = useHeaderButtons(); const [settings, setSettings] = useState({ disabledUrls: [], temporaryDisabledUrls: {}, @@ -46,6 +50,35 @@ const Settings: React.FC = () => { return tab; }; + /** + * Open the client tab. + */ + const openClientTab = async () : Promise => { + const settingClientUrl = await storage.getItem('local:clientUrl') as string; + let clientUrl = AppInfo.DEFAULT_CLIENT_URL; + if (settingClientUrl && settingClientUrl.length > 0) { + clientUrl = settingClientUrl; + } + + window.open(clientUrl, '_blank'); + }; + + // Set header buttons on mount and clear on unmount + useEffect((): (() => void) => { + const headerButtonsJSX = ( +
+ +
+ ); + + setHeaderButtons(headerButtonsJSX); + return () => setHeaderButtons(null); + }, [setHeaderButtons]); + /** * Load settings. */ diff --git a/apps/browser-extension/src/utils/SqliteClient.ts b/apps/browser-extension/src/utils/SqliteClient.ts index 936051389..75e944480 100644 --- a/apps/browser-extension/src/utils/SqliteClient.ts +++ b/apps/browser-extension/src/utils/SqliteClient.ts @@ -341,9 +341,41 @@ export class SqliteClient { /** * Get the default email domain from the database. + * @param privateEmailDomains - Array of private email domains + * @param publicEmailDomains - Array of public email domains + * @returns The default email domain or null if no valid domain is found */ - public getDefaultEmailDomain(): string { - return this.getSetting('DefaultEmailDomain'); + public getDefaultEmailDomain(privateEmailDomains: string[], publicEmailDomains: string[]): string | null { + const defaultEmailDomain = this.getSetting('DefaultEmailDomain'); + + /** + * Check if a domain is valid. + */ + const isValidDomain = (domain: string): boolean => { + return Boolean(domain && + domain !== 'DISABLED.TLD' && + (privateEmailDomains.includes(domain) || publicEmailDomains.includes(domain))); + }; + + // First check if the default domain that is configured in the vault is still valid. + if (defaultEmailDomain && isValidDomain(defaultEmailDomain)) { + return defaultEmailDomain; + } + + // If default domain is not valid, fall back to first available private domain. + const firstPrivate = privateEmailDomains.find(isValidDomain); + if (firstPrivate) { + return firstPrivate; + } + + // Return first valid public domain if no private domains are available. + const firstPublic = publicEmailDomains.find(isValidDomain); + if (firstPublic) { + return firstPublic; + } + + // Return null if no valid domains are found + return null; } /** @@ -383,9 +415,9 @@ export class SqliteClient { /** * Create a new credential with associated entities * @param credential The credential object to insert - * @returns The number of rows modified + * @returns The ID of the created credential */ - public async createCredential(credential: Credential): Promise { + public async createCredential(credential: Credential): Promise { if (!this.db) { throw new Error('Database not initialized'); } @@ -480,7 +512,7 @@ export class SqliteClient { } await this.commitTransaction(); - return 1; + return credentialId; } catch (error) { this.rollbackTransaction();