mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-03-20 15:41:40 -04:00
Add email details page (#541)
This commit is contained in:
@@ -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' },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
186
browser-extensions/chrome/src/pages/EmailDetails.tsx
Normal file
186
browser-extensions/chrome/src/pages/EmailDetails.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
7
browser-extensions/chrome/src/types/webapi/Attachment.ts
Normal file
7
browser-extensions/chrome/src/types/webapi/Attachment.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type Attachment = {
|
||||
id: number;
|
||||
emailId: number;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
filesize: number;
|
||||
}
|
||||
51
browser-extensions/chrome/src/types/webapi/Email.ts
Normal file
51
browser-extensions/chrome/src/types/webapi/Email.ts
Normal 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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user