Make credential edit flow work (#900)

This commit is contained in:
Leendert de Borst
2025-06-09 18:29:58 +02:00
committed by Leendert de Borst
parent 34d00dc7d6
commit 0ccbeb683d
5 changed files with 515 additions and 105 deletions

View File

@@ -0,0 +1,82 @@
import React from 'react';
/**
* Form input props.
*/
type FormInputProps = {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
type?: 'text' | 'password';
placeholder?: string;
required?: boolean;
multiline?: boolean;
rows?: number;
error?: string;
}
/**
* Form input component.
*/
export const FormInput: React.FC<FormInputProps> = ({
id,
label,
value,
onChange,
type = 'text',
placeholder,
required = false,
multiline = false,
rows = 1,
error
}) => {
const [showPassword, setShowPassword] = React.useState(false);
const inputClasses = `mt-1 block w-full rounded-md ${
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-700'
} text-gray-900 sm:text-sm rounded-lg shadow-sm focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400 p-3`;
return (
<div>
<label htmlFor={id} className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
<div className="relative">
{multiline ? (
<textarea
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
rows={rows}
placeholder={placeholder}
className={inputClasses}
/>
) : (
<input
type={type === 'password' && !showPassword ? 'password' : 'text'}
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={inputClasses}
/>
)}
{type === 'password' && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<button
onClick={() => setShowPassword(!showPassword)}
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-900 transition-colors duration-200"
>
{showPassword ? 'Hide' : 'Show'}
</button>
</div>
)}
</div>
{error && (
<p className="mt-1 text-sm text-red-500">{error}</p>
)}
</div>
);
};

View File

@@ -6,7 +6,8 @@ export enum HeaderIconType {
DELETE = 'delete',
SETTINGS = 'settings',
RELOAD = 'reload',
EXTERNAL_LINK = 'external_link'
EXTERNAL_LINK = 'external_link',
SAVE = 'save'
}
type HeaderIconProps = {
@@ -115,7 +116,35 @@ export const HeaderIcon: React.FC<HeaderIconProps> = ({ type, className = 'w-5 h
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
</svg>
)
),
[HeaderIconType.SAVE]: (
<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="M17 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V7l-4-4z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 3v5h10"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 12a2 2 0 100 4 2 2 0 000-4z"
/>
</svg>
),
};
return icons[type] || null;

View File

@@ -27,7 +27,6 @@ const CredentialDetails: React.FC = (): React.ReactElement => {
const [credential, setCredential] = useState<Credential | null>(null);
const { setIsInitialLoading } = useLoading();
const { setHeaderButtons } = useHeaderButtons();
const [headerButtonsConfigured, setHeaderButtonsConfigured] = useState(false);
/**
* Check if the current page is an expanded popup.
@@ -102,28 +101,23 @@ const CredentialDetails: React.FC = (): React.ReactElement => {
// Set header buttons on mount and clear on unmount
useEffect((): (() => void) => {
// Only set the header buttons once on mount.
if (!headerButtonsConfigured) {
const headerButtonsJSX = (
<div className="flex items-center gap-2">
<HeaderButton
onClick={openInNewPopup}
title="Open in new window"
iconType={HeaderIconType.EXPAND}
/>
<HeaderButton
onClick={handleEdit}
title="Edit credential"
iconType={HeaderIconType.EDIT}
/>
</div>
);
setHeaderButtons(headerButtonsJSX);
setHeaderButtonsConfigured(true);
}
const headerButtonsJSX = (
<div className="flex items-center gap-2">
<HeaderButton
onClick={openInNewPopup}
title="Open in new window"
iconType={HeaderIconType.EXPAND}
/>
<HeaderButton
onClick={handleEdit}
title="Edit credential"
iconType={HeaderIconType.EDIT}
/>
</div>
);
setHeaderButtons(headerButtonsJSX);
return () => {};
}, [setHeaderButtons, headerButtonsConfigured, handleEdit, openInNewPopup]);
}, [setHeaderButtons, handleEdit, openInNewPopup]);
// Clear header buttons on unmount
useEffect((): (() => void) => {

View File

@@ -1,23 +1,37 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { FormInput } from '@/entrypoints/popup/components/FormInput';
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
import { HeaderIconType } from '@/entrypoints/popup/components/icons/HeaderIcons';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import { useVaultMutate } from '@/entrypoints/popup/hooks/useVaultMutate';
import type { Credential } from '@/utils/shared/models/vault';
type CredentialMode = 'random' | 'manual';
/**
* Credential edit page.
* Add or edit credential page.
*/
const CredentialEdit: React.FC = () => {
const CredentialAddEdit: React.FC = () => {
const { id } = useParams();
const navigate = useNavigate();
const dbContext = useDb();
const [credential, setCredential] = useState<Credential | null>(null);
const { setIsInitialLoading } = useLoading();
const { executeVaultMutation, isLoading, syncStatus } = useVaultMutate();
const [mode, setMode] = useState<CredentialMode>('random');
const { setHeaderButtons } = useHeaderButtons();
// If we received an ID, we're in edit mode
const isEditMode = id !== undefined && id.length > 0;
/**
* Load an existing credential from the database in edit mode.
*/
useEffect(() => {
if (!dbContext?.sqliteClient || !id) {
return;
@@ -28,6 +42,10 @@ const CredentialEdit: React.FC = () => {
if (result) {
setCredential(result);
setIsInitialLoading(false);
// If credential has alias data, switch to manual mode
if (result.Alias?.FirstName || result.Alias?.LastName) {
setMode('manual');
}
} else {
console.error('Credential not found');
navigate('/credentials');
@@ -39,115 +57,241 @@ const CredentialEdit: React.FC = () => {
/**
* Handle the delete button click.
* @returns {Promise<void>}
*/
const handleDelete = async (): Promise<void> => {
const handleDelete = useCallback(async (): Promise<void> => {
if (!id || !window.confirm('Are you sure you want to delete this credential? This action cannot be undone.')) {
return;
}
/**
* Execute the vault mutation to delete the credential.
*/
executeVaultMutation(async () => {
/**
* Delete the credential from the database.
*/
dbContext.sqliteClient!.deleteCredentialById(id);
}, {
/**
* Navigate back to the credentials list on success.
* Navigate to the credentials list page on success.
*/
onSuccess: () => {
navigate('/credentials');
}
});
};
}, [id, executeVaultMutation, dbContext.sqliteClient, navigate]);
if (!credential) {
/**
* Handle form submission.
*/
const handleSubmit = useCallback(async (): Promise<void> => {
if (!credential) {
return;
}
executeVaultMutation(async () => {
if (isEditMode) {
await dbContext.sqliteClient!.updateCredentialById(credential);
} else {
const credentialId = await dbContext.sqliteClient!.createCredential(credential);
credential.Id = credentialId.toString();
}
}, {
/**
* Navigate to the credential details page on success.
*/
onSuccess: () => {
// Pop the current page from the history stack
navigate(-1);
}
});
}, [credential, isEditMode, dbContext.sqliteClient, executeVaultMutation, navigate]);
// Set header buttons on mount and clear on unmount
useEffect((): (() => void) => {
// Only set the header buttons once on mount.
if (credential) {
const headerButtonsJSX = (
<div className="flex items-center gap-2">
{isEditMode && (
<HeaderButton
onClick={handleDelete}
title="Delete credential"
iconType={HeaderIconType.DELETE}
variant="danger"
/>
)}
<HeaderButton
onClick={handleSubmit}
title="Save credential"
iconType={HeaderIconType.SAVE}
/>
</div>
);
setHeaderButtons(headerButtonsJSX);
}
return () => {};
}, [setHeaderButtons, handleSubmit, credential, isEditMode, handleDelete]);
// Clear header buttons on unmount
useEffect((): (() => void) => {
return () => setHeaderButtons(null);
}, [setHeaderButtons]);
if (!credential && isEditMode) {
return <div>Loading...</div>;
}
return (
<div className="space-y-4 p-4">
<div className="flex justify-between items-center">
<h1 className="text-xl font-bold">Edit Credential</h1>
<button
onClick={() => navigate(`/credentials/${id}`)}
className="text-blue-500 hover:text-blue-700"
>
Cancel
</button>
</div>
<div className="space-y-4">
{isLoading && (
<div className="text-sm text-gray-500">
{syncStatus}
</div>
)}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Service Name</label>
<input
type="text"
value={credential.ServiceName}
onChange={(e) => setCredential({ ...credential, ServiceName: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Service URL</label>
<input
type="text"
value={credential.ServiceUrl}
onChange={(e) => setCredential({ ...credential, ServiceUrl: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Username</label>
<input
type="text"
value={credential.Username}
onChange={(e) => setCredential({ ...credential, Username: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Password</label>
<input
type="password"
value={credential.Password}
onChange={(e) => setCredential({ ...credential, Password: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Notes</label>
<textarea
value={credential.Notes}
onChange={(e) => setCredential({ ...credential, Notes: e.target.value })}
rows={4}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div className="pt-4">
{!isEditMode && (
<div className="flex space-x-2">
<button
onClick={handleDelete}
className="w-full bg-red-500 text-white py-2 px-4 rounded hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
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'
}`}
>
Delete Credential
Random Alias
</button>
<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'
}`}
>
Manual
</button>
</div>
)}
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Service</h2>
<div className="space-y-4">
<FormInput
id="serviceName"
label="Service Name"
value={credential?.ServiceName || ''}
onChange={(value) => setCredential({ ...credential!, ServiceName: value })}
required
/>
<FormInput
id="serviceUrl"
label="Service URL"
value={credential?.ServiceUrl || ''}
onChange={(value) => setCredential({ ...credential!, ServiceUrl: value })}
/>
</div>
</div>
{(mode === 'manual' || isEditMode) && (
<>
<div>
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Login Credentials</h2>
<div className="space-y-4">
<FormInput
id="username"
label="Username"
value={credential?.Username || ''}
onChange={(value) => setCredential({ ...credential!, Username: value })}
/>
<FormInput
id="password"
label="Password"
type="password"
value={credential?.Password || ''}
onChange={(value) => setCredential({ ...credential!, Password: value })}
/>
<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"
>
Generate Random Alias
</button>
<FormInput
id="email"
label="Email"
value={credential?.Alias?.Email || ''}
onChange={(value) => setCredential({
...credential!,
Alias: { ...credential!.Alias, Email: value }
})}
/>
</div>
</div>
<div>
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Alias</h2>
<div className="space-y-4">
<FormInput
id="firstName"
label="First Name"
value={credential?.Alias?.FirstName || ''}
onChange={(value) => setCredential({
...credential!,
Alias: { ...credential!.Alias, FirstName: value }
})}
/>
<FormInput
id="lastName"
label="Last Name"
value={credential?.Alias?.LastName || ''}
onChange={(value) => setCredential({
...credential!,
Alias: { ...credential!.Alias, LastName: value }
})}
/>
<FormInput
id="nickName"
label="Nick Name"
value={credential?.Alias?.NickName || ''}
onChange={(value) => setCredential({
...credential!,
Alias: { ...credential!.Alias, NickName: value }
})}
/>
<FormInput
id="gender"
label="Gender"
value={credential?.Alias?.Gender || ''}
onChange={(value) => setCredential({
...credential!,
Alias: { ...credential!.Alias, Gender: value }
})}
/>
<FormInput
id="birthDate"
label="Birth Date"
placeholder="YYYY-MM-DD"
value={credential?.Alias?.BirthDate || ''}
onChange={(value) => setCredential({
...credential!,
Alias: { ...credential!.Alias, BirthDate: value }
})}
/>
</div>
</div>
<div>
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Metadata</h2>
<div className="space-y-4">
<FormInput
id="notes"
label="Notes"
value={credential?.Notes || ''}
onChange={(value) => setCredential({ ...credential!, Notes: value })}
multiline
rows={4}
/>
</div>
</div>
</>
)}
</div>
</div>
);
};
export default CredentialEdit;
export default CredentialAddEdit;

View File

@@ -622,6 +622,167 @@ export class SqliteClient {
}
}
/**
* Update an existing credential with associated entities
* @param credential The credential object to update
* @returns The number of rows modified
*/
public async updateCredentialById(credential: Credential): Promise<number> {
if (!this.db) {
throw new Error('Database not initialized');
}
try {
this.beginTransaction();
const currentDateTime = new Date().toISOString()
.replace('T', ' ')
.replace('Z', '')
.substring(0, 23);
// Get existing credential to compare changes
const existingCredential = this.getCredentialById(credential.Id);
if (!existingCredential) {
throw new Error('Credential not found');
}
// 1. Update Service
const serviceQuery = `
UPDATE Services
SET Name = ?,
Url = ?,
Logo = COALESCE(?, Logo),
UpdatedAt = ?
WHERE Id = (
SELECT ServiceId
FROM Credentials
WHERE Id = ?
)`;
let logoData = null;
try {
if (credential.Logo) {
// Handle object-like array conversion
if (typeof credential.Logo === 'object' && !ArrayBuffer.isView(credential.Logo)) {
const values = Object.values(credential.Logo);
logoData = new Uint8Array(values);
// Handle existing array types
} else if (Array.isArray(credential.Logo) || credential.Logo instanceof ArrayBuffer || credential.Logo instanceof Uint8Array) {
logoData = new Uint8Array(credential.Logo);
}
}
} catch (error) {
console.warn('Failed to convert logo to Uint8Array:', error);
logoData = null;
}
this.executeUpdate(serviceQuery, [
credential.ServiceName,
credential.ServiceUrl ?? null,
logoData,
currentDateTime,
credential.Id
]);
// 2. Update Alias
const aliasQuery = `
UPDATE Aliases
SET FirstName = ?,
LastName = ?,
NickName = ?,
BirthDate = ?,
Gender = ?,
Email = ?,
UpdatedAt = ?
WHERE Id = (
SELECT AliasId
FROM Credentials
WHERE Id = ?
)`;
// Only update BirthDate if it's actually different (accounting for format differences)
let birthDate = credential.Alias.BirthDate;
if (birthDate && existingCredential.Alias.BirthDate) {
const newDate = new Date(birthDate);
const existingDate = new Date(existingCredential.Alias.BirthDate);
if (newDate.getTime() === existingDate.getTime()) {
birthDate = existingCredential.Alias.BirthDate;
}
}
this.executeUpdate(aliasQuery, [
credential.Alias.FirstName ?? null,
credential.Alias.LastName ?? null,
credential.Alias.NickName ?? null,
birthDate ?? null,
credential.Alias.Gender ?? null,
credential.Alias.Email ?? null,
currentDateTime,
credential.Id
]);
// 3. Update Credential
const credentialQuery = `
UPDATE Credentials
SET Username = ?,
Notes = ?,
UpdatedAt = ?
WHERE Id = ?`;
this.executeUpdate(credentialQuery, [
credential.Username ?? null,
credential.Notes ?? null,
currentDateTime,
credential.Id
]);
// 4. Update Password if changed
if (credential.Password !== existingCredential.Password) {
// Check if a password record already exists for this credential, if not, then create one.
const passwordRecordExistsQuery = `
SELECT Id
FROM Passwords
WHERE CredentialId = ?`;
const passwordResults = this.executeQuery(passwordRecordExistsQuery, [credential.Id]);
if (passwordResults.length === 0) {
// Create a new password record
const passwordQuery = `
INSERT INTO Passwords (Id, Value, CredentialId, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?)`;
this.executeUpdate(passwordQuery, [
crypto.randomUUID().toUpperCase(),
credential.Password,
credential.Id,
currentDateTime,
currentDateTime,
0
]);
} else {
// Update the existing password record
const passwordQuery = `
UPDATE Passwords
SET Value = ?, UpdatedAt = ?
WHERE CredentialId = ?`;
this.executeUpdate(passwordQuery, [
credential.Password,
currentDateTime,
credential.Id
]);
}
}
await this.commitTransaction();
return 1;
} catch (error) {
this.rollbackTransaction();
console.error('Error updating credential:', error);
throw error;
}
}
/**
* Convert binary data to a base64 encoded image source.
*/