Add credential add page (#900)

This commit is contained in:
Leendert de Borst
2025-06-09 20:26:14 +02:00
committed by Leendert de Borst
parent 3da40f42c9
commit c688764831
9 changed files with 226 additions and 95 deletions

View File

@@ -226,48 +226,16 @@ export async function getEmailAddressesForVault(
/**
* Get default email domain for a vault.
*/
export function handleGetDefaultEmailDomain(
) : Promise<stringResponse> {
return (async () : Promise<stringResponse> => {
export function handleGetDefaultEmailDomain(): Promise<stringResponse> {
return (async (): Promise<stringResponse> => {
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' };

View File

@@ -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: <Home />, showBackButton: false },
{ path: '/auth-settings', element: <AuthSettings />, showBackButton: true, title: 'Settings' },
{ path: '/credentials', element: <CredentialsList />, showBackButton: false },
{ path: '/credentials/add', element: <CredentialAddEdit />, showBackButton: true, title: 'Add credential' },
{ path: '/credentials/:id', element: <CredentialDetails />, showBackButton: true, title: 'Credential details' },
{ path: '/credentials/:id/edit', element: <CredentialEdit />, showBackButton: true, title: 'Edit credential' },
{ path: '/credentials/:id/edit', element: <CredentialAddEdit />, showBackButton: true, title: 'Edit credential' },
{ path: '/emails', element: <EmailsList />, showBackButton: false },
{ path: '/emails/:id', element: <EmailDetails />, showBackButton: true, title: 'Email details' },
{ path: '/settings', element: <Settings />, showBackButton: false },

View File

@@ -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<HeaderProps> = ({
const navigate = useNavigate();
const location = useLocation();
/**
* Open the client tab.
*/
const openClientTab = async () : Promise<void> => {
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<HeaderProps> = ({
<div className="flex-grow" />
<div className="flex items-center gap-2">
{!currentRoute?.showBackButton && (
<HeaderButton
onClick={openClientTab}
title="Open Client"
iconType={HeaderIconType.EXTERNAL_LINK}
/>
)}
{!authContext.isLoggedIn ? (
<button
id="settings"

View File

@@ -7,7 +7,8 @@ export enum HeaderIconType {
SETTINGS = 'settings',
RELOAD = 'reload',
EXTERNAL_LINK = 'external_link',
SAVE = 'save'
SAVE = 'save',
PLUS = 'plus'
}
type HeaderIconProps = {
@@ -145,6 +146,17 @@ export const HeaderIcon: React.FC<HeaderIconProps> = ({ type, className = 'w-5 h
/>
</svg>
),
[HeaderIconType.PLUS]: (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
)
};
return icons[type] || null;

View File

@@ -11,8 +11,9 @@ import { useDb } from '@/entrypoints/popup/context/DbContext';
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
import { useVaultMutate } from '@/entrypoints/popup/hooks/useVaultMutate';
import { IdentityHelperUtils } from '@/utils/shared/identity-generator';
import { IdentityHelperUtils, CreateIdentityGenerator } from '@/utils/shared/identity-generator';
import type { Credential } from '@/utils/shared/models/vault';
import { CreatePasswordGenerator } from '@/utils/shared/password-generator';
import LoadingSpinner from '../components/LoadingSpinner';
import { useLoading } from '../context/LoadingContext';
@@ -137,6 +138,69 @@ const CredentialAddEdit: React.FC = () => {
});
}, [id, executeVaultMutation, dbContext.sqliteClient, navigate]);
/**
* Initialize the identity and password generators with settings from user's vault.
*/
const initializeGenerators = useCallback(async () => {
// Get default identity language from database
const identityLanguage = dbContext.sqliteClient!.getDefaultIdentityLanguage();
// Initialize identity generator based on language
const identityGenerator = CreateIdentityGenerator(identityLanguage);
// Initialize password generator with settings from vault
const passwordSettings = dbContext.sqliteClient!.getPasswordSettings();
const passwordGenerator = CreatePasswordGenerator(passwordSettings);
return { identityGenerator, passwordGenerator };
}, [dbContext.sqliteClient]);
/**
* Generate a random alias and password.
*/
const generateRandomAlias = useCallback(async () => {
const { identityGenerator, passwordGenerator } = await initializeGenerators();
const identity = identityGenerator.generateRandomIdentity();
const password = passwordGenerator.generateRandomPassword();
const metadata = await dbContext!.getVaultMetadata();
const privateEmailDomains = metadata?.privateEmailDomains ?? [];
const publicEmailDomains = metadata?.publicEmailDomains ?? [];
const defaultEmailDomain = dbContext.sqliteClient!.getDefaultEmailDomain(privateEmailDomains, publicEmailDomains);
const email = defaultEmailDomain ? `${identity.emailPrefix}@${defaultEmailDomain}` : identity.emailPrefix;
setValue('Alias.Email', email);
setValue('Alias.FirstName', identity.firstName);
setValue('Alias.LastName', identity.lastName);
setValue('Alias.NickName', identity.nickName);
setValue('Alias.Gender', identity.gender);
setValue('Alias.BirthDate', IdentityHelperUtils.normalizeBirthDateForDisplay(identity.birthDate.toISOString()));
// In edit mode, preserve existing username and password if they exist
if (isEditMode && watch('Username')) {
// Keep the existing username in edit mode, so don't do anything here.
} else {
// Use the newly generated username
setValue('Username', identity.nickName);
}
if (isEditMode && watch('Password')) {
// Keep the existing password in edit mode, so don't do anything here.
} else {
// Use the newly generated password
setValue('Password', password);
}
}, [isEditMode, watch, setValue, initializeGenerators, dbContext]);
/**
* Handle the generate random alias button press.
*/
const handleGenerateRandomAlias = useCallback(() => {
void generateRandomAlias();
}, [generateRandomAlias]);
/**
* Handle form submission.
*/
@@ -146,6 +210,20 @@ const CredentialAddEdit: React.FC = () => {
data.Alias.BirthDate = IdentityHelperUtils.normalizeBirthDateForDb(data.Alias.BirthDate);
}
// If we're creating a new credential and mode is random, generate random values here
if (!isEditMode && mode === 'random') {
// Generate random values now and then read them from the form fields to manually assign to the credentialToSave object
await generateRandomAlias();
data.Username = watch('Username');
data.Password = watch('Password');
data.Alias.FirstName = watch('Alias.FirstName');
data.Alias.LastName = watch('Alias.LastName');
data.Alias.NickName = watch('Alias.NickName');
data.Alias.BirthDate = watch('Alias.BirthDate');
data.Alias.Gender = watch('Alias.Gender');
data.Alias.Email = watch('Alias.Email');
}
executeVaultMutation(async () => {
if (isEditMode) {
await dbContext.sqliteClient!.updateCredentialById(data);
@@ -158,11 +236,17 @@ const CredentialAddEdit: React.FC = () => {
* Navigate to the credential details page on success.
*/
onSuccess: () => {
// Pop the current page from the history stack
navigate(-1);
// If in add mode, navigate to the credential details page.
if (!isEditMode) {
// Navigate to the credential details page.
navigate(`/credentials/${data.Id}`, { replace: true });
} else {
// If in edit mode, pop the current page from the history stack to end up on details page as well.
navigate(-1);
}
},
});
}, [isEditMode, dbContext.sqliteClient, executeVaultMutation, navigate]);
}, [isEditMode, dbContext.sqliteClient, executeVaultMutation, navigate, mode, watch, generateRandomAlias]);
// Set header buttons on mount and clear on unmount
useEffect((): (() => void) => {
@@ -194,7 +278,7 @@ const CredentialAddEdit: React.FC = () => {
return () => setHeaderButtons(null);
}, [setHeaderButtons]);
if (!isEditMode && !watch('ServiceName')) {
if (isEditMode && !watch('ServiceName')) {
return <div>Loading...</div>;
}
@@ -214,7 +298,7 @@ const CredentialAddEdit: React.FC = () => {
<button
onClick={() => setMode('random')}
className={`flex-1 py-2 px-4 rounded ${
mode === 'random' ? 'bg-blue-500 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
mode === 'random' ? 'bg-primary-500 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
Random Alias
@@ -222,7 +306,7 @@ const CredentialAddEdit: React.FC = () => {
<button
onClick={() => setMode('manual')}
className={`flex-1 py-2 px-4 rounded ${
mode === 'manual' ? 'bg-blue-500 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
mode === 'manual' ? 'bg-primary-500 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
Manual
@@ -237,7 +321,7 @@ const CredentialAddEdit: React.FC = () => {
<FormInput
id="serviceName"
label="Service Name"
value={watch('ServiceName')}
value={watch('ServiceName') ?? ''}
onChange={(value) => setValue('ServiceName', value)}
required
error={errors.ServiceName?.message}
@@ -245,7 +329,7 @@ const CredentialAddEdit: React.FC = () => {
<FormInput
id="serviceUrl"
label="Service URL"
value={watch('ServiceUrl')}
value={watch('ServiceUrl') ?? ''}
onChange={(value) => setValue('ServiceUrl', value)}
error={errors.ServiceUrl?.message}
/>
@@ -260,7 +344,7 @@ const CredentialAddEdit: React.FC = () => {
<FormInput
id="username"
label="Username"
value={watch('Username')}
value={watch('Username') ?? ''}
onChange={(value) => setValue('Username', value)}
error={errors.Username?.message}
/>
@@ -268,20 +352,20 @@ const CredentialAddEdit: React.FC = () => {
id="password"
label="Password"
type="password"
value={watch('Password')}
value={watch('Password') ?? ''}
onChange={(value) => setValue('Password', value)}
error={errors.Password?.message}
/>
<button
onClick={() => {/* TODO: Implement generate random alias */}}
className="w-full bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
onClick={handleGenerateRandomAlias}
className="w-full bg-primary-500 text-white py-2 px-4 rounded hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
>
Generate Random Alias
</button>
<FormInput
id="email"
label="Email"
value={watch('Alias.Email')}
value={watch('Alias.Email') ?? ''}
onChange={(value) => setValue('Alias.Email', value)}
error={errors.Alias?.Email?.message}
/>
@@ -294,28 +378,28 @@ const CredentialAddEdit: React.FC = () => {
<FormInput
id="firstName"
label="First Name"
value={watch('Alias.FirstName')}
value={watch('Alias.FirstName') ?? ''}
onChange={(value) => setValue('Alias.FirstName', value)}
error={errors.Alias?.FirstName?.message}
/>
<FormInput
id="lastName"
label="Last Name"
value={watch('Alias.LastName')}
value={watch('Alias.LastName') ?? ''}
onChange={(value) => setValue('Alias.LastName', value)}
error={errors.Alias?.LastName?.message}
/>
<FormInput
id="nickName"
label="Nick Name"
value={watch('Alias.NickName')}
value={watch('Alias.NickName') ?? ''}
onChange={(value) => setValue('Alias.NickName', value)}
error={errors.Alias?.NickName?.message}
/>
<FormInput
id="gender"
label="Gender"
value={watch('Alias.Gender')}
value={watch('Alias.Gender') ?? ''}
onChange={(value) => 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 = () => {
<FormInput
id="notes"
label="Notes"
value={watch('Notes')}
value={watch('Notes') ?? ''}
onChange={(value) => setValue('Notes', value)}
multiline
rows={4}

View File

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

View File

@@ -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<Credential[]>([]);
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 = (
<div className="flex items-center gap-2">
<HeaderButton
onClick={handleAddCredential}
title="Add new credential"
iconType={HeaderIconType.PLUS}
/>
</div>
);
setHeaderButtons(headerButtonsJSX);
return () => setHeaderButtons(null);
}, [setHeaderButtons, handleAddCredential]);
/**
* Load credentials list on mount and on sqlite client change.
*/

View File

@@ -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<PopupSettings>({
disabledUrls: [],
temporaryDisabledUrls: {},
@@ -46,6 +50,35 @@ const Settings: React.FC = () => {
return tab;
};
/**
* Open the client tab.
*/
const openClientTab = async () : Promise<void> => {
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 = (
<div className="flex items-center gap-2">
<HeaderButton
onClick={openClientTab}
title="Open Client"
iconType={HeaderIconType.EXTERNAL_LINK}
/>
</div>
);
setHeaderButtons(headerButtonsJSX);
return () => setHeaderButtons(null);
}, [setHeaderButtons]);
/**
* Load settings.
*/

View File

@@ -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<number> {
public async createCredential(credential: Credential): Promise<string> {
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();