Add email details page (#541)

This commit is contained in:
Leendert de Borst
2025-01-31 15:46:41 +01:00
parent 48b6acb174
commit bc96d30bf4
10 changed files with 259 additions and 14 deletions

View File

@@ -2,7 +2,6 @@ import React, { useState, useEffect, useRef } from 'react';
import { HashRouter as Router, Routes, Route, Navigate, useNavigate } from 'react-router-dom';
import { useAuth } from './context/AuthContext';
import { useDb } from './context/DbContext';
import { useWebApi } from './context/WebApiContext';
import { useMinDurationLoading } from './hooks/useMinDurationLoading';
import Header from './components/Layout/Header';
import BottomNav from './components/Layout/BottomNav';
@@ -13,6 +12,7 @@ import LoadingSpinner from './components/LoadingSpinner';
import Home from './pages/Home';
import './styles/app.css';
import CredentialDetails from './pages/CredentialDetails';
import EmailDetails from './pages/EmailDetails';
// Add this type definition at the top level
type RouteConfig = {
path: string;
@@ -24,7 +24,6 @@ type RouteConfig = {
const App: React.FC = () => {
const authContext = useAuth();
const dbContext = useDb();
const webApi = useWebApi();
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const [clientUrl, setClientUrl] = useState('https://app.aliasvault.net');
const [currentTab, setCurrentTab] = useState<'credentials' | 'emails'>('credentials');
@@ -35,8 +34,9 @@ const App: React.FC = () => {
{ path: '/', element: <Home />, showBackButton: false },
{ path: '/settings', element: <Settings />, showBackButton: true, title: 'Settings' },
{ path: '/credentials', element: <CredentialsList />, showBackButton: false },
{ path: '/credentials/:id', element: <CredentialDetails />, showBackButton: true, title: 'Credential Details' },
{ path: '/credentials/:id', element: <CredentialDetails />, showBackButton: true, title: 'Credential details' },
{ path: '/emails', element: <EmailsList />, showBackButton: false },
{ path: '/emails/:id', element: <EmailDetails />, showBackButton: true, title: 'Email details' },
];
/**

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import { useDb } from '../../context/DbContext';
interface BottomNavProps {
currentTab: 'credentials' | 'emails';
setCurrentTab: (tab: 'credentials' | 'emails') => void;
@@ -9,6 +9,7 @@ interface BottomNavProps {
const BottomNav: React.FC<BottomNavProps> = ({ currentTab, setCurrentTab }) => {
const authContext = useAuth();
const dbContext = useDb();
const navigate = useNavigate();
const handleTabChange = (tab: 'credentials' | 'emails') => {
@@ -16,7 +17,7 @@ const BottomNav: React.FC<BottomNavProps> = ({ currentTab, setCurrentTab }) => {
navigate(`/${tab}`);
};
if (!authContext.isLoggedIn) {
if (!authContext.isLoggedIn || !dbContext.dbAvailable) {
return null;
}

View File

@@ -87,7 +87,7 @@ const CredentialDetails: React.FC = () => {
</div>
<button
onClick={openInNewPopup}
className="p-2 text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
className="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
title="Open in new window"
>
<svg

View File

@@ -0,0 +1,186 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Email } from '../types/webapi/Email';
import { useDb } from '../context/DbContext';
import { useWebApi } from '../context/WebApiContext';
import LoadingSpinner from '../components/LoadingSpinner';
import { useMinDurationLoading } from '../hooks/useMinDurationLoading';
import EncryptionUtility from '../utils/EncryptionUtility';
import { Buffer } from 'buffer';
const EmailDetails: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const dbContext = useDb();
const webApi = useWebApi();
const [error, setError] = useState<string | null>(null);
const [email, setEmail] = useState<Email | null>(null);
const [isLoading, setIsLoading] = useMinDurationLoading(true, 150);
useEffect(() => {
const loadEmail = async () => {
try {
setIsLoading(true);
setError(null);
if (!dbContext?.sqliteClient || !id) {
return;
}
const response = await webApi.get<Email>(`Email/${id}`);
// Decrypt email locally using private key
const encryptionKeys = dbContext.sqliteClient.getAllEncryptionKeys();
const encrytionKey = encryptionKeys.find(key => key.PublicKey === response.encryptionKey);
if (!encrytionKey) {
throw new Error('Encryption key not found');
}
// Decrypt symmetric key with assymetric private key
const symmetricKey = await EncryptionUtility.decryptWithPrivateKey(
response.encryptedSymmetricKey,
encrytionKey.PrivateKey
);
const symmetricKeyBase64 = Buffer.from(symmetricKey).toString('base64');
// Decrypt all email fields
response.subject = await EncryptionUtility.symmetricDecrypt(response.subject, symmetricKeyBase64);
response.fromDisplay = await EncryptionUtility.symmetricDecrypt(response.fromDisplay, symmetricKeyBase64);
response.fromDomain = await EncryptionUtility.symmetricDecrypt(response.fromDomain, symmetricKeyBase64);
response.fromLocal = await EncryptionUtility.symmetricDecrypt(response.fromLocal, symmetricKeyBase64);
if (response.messageHtml) {
response.messageHtml = await EncryptionUtility.symmetricDecrypt(response.messageHtml, symmetricKeyBase64);
}
if (response.messagePlain) {
response.messagePlain = await EncryptionUtility.symmetricDecrypt(response.messagePlain, symmetricKeyBase64);
}
setEmail(response);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setIsLoading(false);
}
};
loadEmail();
}, [id, dbContext?.sqliteClient, webApi]);
const handleDelete = async () => {
try {
await webApi.delete(`Email/${id}`);
navigate('/emails');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete email');
}
};
if (isLoading) {
return (
<div className="flex justify-center items-center p-8">
<LoadingSpinner />
</div>
);
}
if (error) {
return <div className="text-red-500">Error: {error}</div>;
}
if (!email) {
return <div className="text-gray-500">Email not found</div>;
}
return (
<div className="max-w-4xl mx-auto">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md">
{/* Header */}
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex justify-between items-start mb-4">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{email.subject}</h1>
<div className="flex space-x-2">
<button
onClick={handleDelete}
className="p-2 text-red-500 hover:text-red-600 rounded-md hover:bg-red-100 dark:hover:bg-red-900/20"
title="Delete email"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<div className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
<p>From: {email.fromDisplay} ({email.fromLocal}@{email.fromDomain})</p>
<p>To: {email.toLocal}@{email.toDomain}</p>
<p>Date: {new Date(email.dateSystem).toLocaleString()}</p>
</div>
</div>
{/* Email Body */}
<div className="p-6">
{email.messageHtml ? (
<iframe
srcDoc={email.messageHtml}
className="w-full min-h-[500px] border-0"
title="Email content"
/>
) : (
<pre className="whitespace-pre-wrap text-gray-700 dark:text-gray-300">
{email.messagePlain}
</pre>
)}
</div>
{/* Attachments */}
{email.attachments && email.attachments.length > 0 && (
<div className="p-6 border-t border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
Attachments
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{email.attachments.map((attachment) => (
<div
key={attachment.id}
className="flex items-center space-x-2 text-sm text-gray-600 dark:text-gray-400"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
/>
</svg>
<span>
{attachment.filename} ({Math.ceil(attachment.filesize / 1024)} KB)
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
};
export default EmailDetails;

View File

@@ -8,6 +8,7 @@ import { useMinDurationLoading } from '../hooks/useMinDurationLoading';
import EncryptionUtility from '../utils/EncryptionUtility';
import { Buffer } from 'buffer';
import ReloadButton from '../components/ReloadButton';
import { Link } from 'react-router-dom';
/**
* Emails list page.
*/
@@ -149,9 +150,10 @@ const EmailsList: React.FC = () => {
</div>
<div className="space-y-2">
{emails.map((email) => (
<div
<Link
key={email.id}
className="p-4 bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow border border-gray-200 dark:border-gray-700"
to={`/emails/${email.id}`}
className="block p-4 bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700"
>
<div className="flex justify-between items-start mb-2">
<div className="text-sm text-gray-600 dark:text-gray-400">
@@ -167,7 +169,7 @@ const EmailsList: React.FC = () => {
<div className="text-sm text-gray-600 dark:text-gray-300 line-clamp-2">
{email.messagePreview}
</div>
</div>
</Link>
))}
</div>
</div>

View File

@@ -12,7 +12,7 @@ const Home: React.FC = () => {
const dbContext = useDb();
const navigate = useNavigate();
const [isInlineUnlockMode, setIsInlineUnlockMode] = useState(false);
const needsUnlock = !authContext.isLoggedIn || !authContext.isInitialized || !dbContext.dbAvailable || !dbContext.dbInitialized;
const needsUnlock = (!authContext.isLoggedIn && authContext.isInitialized) || (!dbContext.dbAvailable && dbContext.dbInitialized);
useEffect(() => {
if (isLoggedIn && !needsUnlock && !isInlineUnlockMode) {
@@ -23,12 +23,10 @@ const Home: React.FC = () => {
if (!isLoggedIn) {
console.log('not logged in');
return <Login />;
}
if (needsUnlock) {
console.log('needs unlock');
return <Unlock />;
}

View File

@@ -20,7 +20,7 @@ const Login: React.FC = () => {
password: '',
});
const { showLoading, hideLoading } = useLoading();
const [rememberMe, setRememberMe] = useState(false);
const [rememberMe, setRememberMe] = useState(true);
const [error, setError] = useState<string | null>(null);
const webApi = useWebApi();
const srpUtil = new SrpUtility(webApi);

View File

@@ -0,0 +1,7 @@
export type Attachment = {
id: number;
emailId: number;
filename: string;
mimeType: string;
filesize: number;
}

View File

@@ -0,0 +1,51 @@
import { Attachment } from "./Attachment";
export type Email = {
/** The body of the email message */
messageHtml: string;
/** The plain text body of the email message */
messagePlain: string;
/** The ID of the email */
id: number;
/** The subject of the email */
subject: string;
/** The display name of the sender */
fromDisplay: string;
/** The domain of the sender's email address */
fromDomain: string;
/** The local part of the sender's email address */
fromLocal: string;
/** The domain of the recipient's email address */
toDomain: string;
/** The local part of the recipient's email address */
toLocal: string;
/** The date of the email */
date: string;
/** The system date of the email */
dateSystem: string;
/** The number of seconds ago the email was received */
secondsAgo: number;
/**
* The encrypted symmetric key which was used to encrypt the email message.
* This key is encrypted with the public key of the user.
*/
encryptedSymmetricKey: string;
/** The public key of the user used to encrypt the symmetric key */
encryptionKey: string;
/** The attachments of the email */
attachments: Attachment[];
}

View File

@@ -173,7 +173,7 @@ export class WebApiService {
* Issue DELETE request to the API.
*/
public async delete<T>(endpoint: string): Promise<T> {
return this.fetch<T>(endpoint, { method: 'DELETE' });
return this.fetch<T>(endpoint, { method: 'DELETE' }, false);
}
/**