diff --git a/apps/browser-extension/src/entrypoints/popup/App.tsx b/apps/browser-extension/src/entrypoints/popup/App.tsx index 1962e3312..c2263e1ef 100644 --- a/apps/browser-extension/src/entrypoints/popup/App.tsx +++ b/apps/browser-extension/src/entrypoints/popup/App.tsx @@ -6,6 +6,7 @@ import BottomNav from '@/entrypoints/popup/components/Layout/BottomNav'; import Header from '@/entrypoints/popup/components/Layout/Header'; import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner'; 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 CredentialDetails from '@/entrypoints/popup/pages/CredentialDetails'; @@ -38,6 +39,7 @@ const App: React.FC = () => { const { isInitialLoading } = useLoading(); const [isLoading, setIsLoading] = useMinDurationLoading(true, 150); const [message, setMessage] = useState(null); + const { headerButtons } = useHeaderButtons(); // Add these route configurations const routes: RouteConfig[] = [ @@ -81,6 +83,7 @@ const App: React.FC = () => {
void; } /** * Render the header block. */ -const HeaderBlock: React.FC = ({ credential, onOpenNewPopup }) => ( -
-
-
- {credential.ServiceName} -
-

{credential.ServiceName}

- {credential.ServiceUrl && ( - - {credential.ServiceUrl} - - )} -
+const HeaderBlock: React.FC = ({ credential }) => ( +
+
+ {credential.ServiceName} +
+

{credential.ServiceName}

+ {credential.ServiceUrl && ( + + {credential.ServiceUrl} + + )}
-
); diff --git a/apps/browser-extension/src/entrypoints/popup/components/HeaderButton.tsx b/apps/browser-extension/src/entrypoints/popup/components/HeaderButton.tsx new file mode 100644 index 000000000..728536257 --- /dev/null +++ b/apps/browser-extension/src/entrypoints/popup/components/HeaderButton.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import { HeaderIcon, HeaderIconType } from './icons/HeaderIcons'; + +type HeaderButtonProps = { + onClick: () => void; + title: string; + iconType: HeaderIconType; + variant?: 'default' | 'primary' | 'danger'; +}; + +/** + * Header button component for consistent header button styling + */ +const HeaderButton: React.FC = ({ + onClick, + title, + iconType, + variant = 'default' +}) => { + const colorClasses = { + default: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700', + primary: 'text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 hover:bg-primary-100 dark:hover:bg-primary-900/20', + danger: 'text-red-500 hover:text-red-600 hover:bg-red-100 dark:hover:bg-red-900/20' + }; + + return ( + + ); +}; + +export default HeaderButton; \ No newline at end of file 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 a9d43170f..89d8b527f 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/Layout/Header.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/Layout/Header.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; -import { UserMenu } from '@/entrypoints/popup/components/Layout/UserMenu'; +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'; @@ -17,13 +18,15 @@ type HeaderProps = { showBackButton?: boolean; title?: string; }[]; + rightButtons?: React.ReactNode; } /** * Header component. */ const Header: React.FC = ({ - routes = [] + routes = [], + rightButtons }) => { const authContext = useAuth(); const navigate = useNavigate(); @@ -108,33 +111,29 @@ const Header: React.FC = ({
-
- {!currentRoute?.showBackButton ? ( - - ) : (<>)} + ) : ( + rightButtons + )}
- {!authContext.isLoggedIn ? ( - - ) : ( - - )}
); diff --git a/apps/browser-extension/src/entrypoints/popup/components/Layout/UserMenu.tsx b/apps/browser-extension/src/entrypoints/popup/components/Layout/UserMenu.tsx index e298fb054..660d0fded 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/Layout/UserMenu.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/Layout/UserMenu.tsx @@ -1,89 +1,49 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '@/entrypoints/popup/context/AuthContext'; -import { useLoading } from '@/entrypoints/popup/context/LoadingContext'; /** * User menu component. */ -export const UserMenu: React.FC = () => { +const UserMenu: React.FC = () => { const authContext = useAuth(); - const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); - const menuRef = useRef(null); - const buttonRef = useRef(null); const navigate = useNavigate(); - const { showLoading, hideLoading } = useLoading(); - - useEffect(() => { - /** - * Handle clicking outside the user menu. - */ - const handleClickOutside = (event: MouseEvent) : void => { - if ( - menuRef.current && - buttonRef.current && - !menuRef.current.contains(event.target as Node) && - !buttonRef.current.contains(event.target as Node) - ) { - setIsUserMenuOpen(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () : void => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); /** - * Toggle the user menu. + * Handle logout. */ - const toggleUserMenu = () : void => { - setIsUserMenuOpen(!isUserMenuOpen); - }; - - /** - * Handle logging out. - */ - const onLogout = async () : Promise => { - showLoading(); - navigate('/logout', { replace: true }); - hideLoading(); + const handleLogout = async () : Promise => { + await authContext.logout(); + navigate('/'); }; return ( -
-
- - - {isUserMenuOpen && ( -
-
- - {authContext.username} +
+
+
+
+
+ + {authContext.username?.[0]?.toUpperCase() || '?'}
-
- )} +
+

+ {authContext.username} +

+

+ Logged in +

+
+
+
); diff --git a/apps/browser-extension/src/entrypoints/popup/components/icons/HeaderIcons.tsx b/apps/browser-extension/src/entrypoints/popup/components/icons/HeaderIcons.tsx new file mode 100644 index 000000000..e2c1046ab --- /dev/null +++ b/apps/browser-extension/src/entrypoints/popup/components/icons/HeaderIcons.tsx @@ -0,0 +1,122 @@ +import React from 'react'; + +export enum HeaderIconType { + EXPAND = 'expand', + EDIT = 'edit', + DELETE = 'delete', + SETTINGS = 'settings', + RELOAD = 'reload', + EXTERNAL_LINK = 'external_link' +} + +type HeaderIconProps = { + type: HeaderIconType; + className?: string; +}; + +/** + * Component to render header icons + */ +export const HeaderIcon: React.FC = ({ type, className = 'w-5 h-5' }) => { + const icons = { + [HeaderIconType.EXPAND]: ( + + + + ), + [HeaderIconType.EDIT]: ( + + + + ), + [HeaderIconType.DELETE]: ( + + + + ), + [HeaderIconType.SETTINGS]: ( + + + + + ), + [HeaderIconType.RELOAD]: ( + + + + ), + [HeaderIconType.EXTERNAL_LINK]: ( + + + + + ) + }; + + return icons[type] || null; +}; \ No newline at end of file diff --git a/apps/browser-extension/src/entrypoints/popup/context/HeaderButtonsContext.tsx b/apps/browser-extension/src/entrypoints/popup/context/HeaderButtonsContext.tsx new file mode 100644 index 000000000..12418d8bf --- /dev/null +++ b/apps/browser-extension/src/entrypoints/popup/context/HeaderButtonsContext.tsx @@ -0,0 +1,48 @@ +import React, { createContext, useContext, useState, useCallback, useMemo } from "react"; + +type HeaderButtonsContextType = { + setHeaderButtons: (buttons: React.ReactNode) => void; + headerButtons: React.ReactNode; +} + +/** + * Context for managing header buttons in the popup + */ +export const HeaderButtonsContext = createContext(undefined); + +/** + * Provider component for HeaderButtonsContext + */ +export const HeaderButtonsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [headerButtons, setHeaderButtons] = useState(null); + + const handleSetHeaderButtons = useCallback((buttons: React.ReactNode) => { + setHeaderButtons(buttons); + }, []); + + const value = useMemo(() => ({ + setHeaderButtons: handleSetHeaderButtons, + headerButtons + }), [handleSetHeaderButtons, headerButtons]); + + return ( + + {children} + + ); +}; + +/** + * Hook to use the HeaderButtonsContext + * @returns The HeaderButtonsContext value + */ +export const useHeaderButtons = (): { + setHeaderButtons: (buttons: React.ReactNode) => void; + headerButtons: React.ReactNode; +} => { + const context = useContext(HeaderButtonsContext); + if (context === undefined) { + throw new Error("useHeaderButtons must be used within a HeaderButtonsProvider"); + } + return context; +}; \ No newline at end of file diff --git a/apps/browser-extension/src/entrypoints/popup/main.tsx b/apps/browser-extension/src/entrypoints/popup/main.tsx index 7bf2aae0e..2b9770446 100644 --- a/apps/browser-extension/src/entrypoints/popup/main.tsx +++ b/apps/browser-extension/src/entrypoints/popup/main.tsx @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'; import App from '@/entrypoints/popup/App'; import { AuthProvider } from '@/entrypoints/popup/context/AuthContext'; import { DbProvider } from '@/entrypoints/popup/context/DbContext'; +import { HeaderButtonsProvider } from '@/entrypoints/popup/context/HeaderButtonsContext'; import { LoadingProvider } from '@/entrypoints/popup/context/LoadingContext'; import { ThemeProvider } from '@/entrypoints/popup/context/ThemeContext'; import { WebApiProvider } from '@/entrypoints/popup/context/WebApiContext'; @@ -18,9 +19,11 @@ root.render( - - - + + + + + diff --git a/apps/browser-extension/src/entrypoints/popup/pages/CredentialDetails.tsx b/apps/browser-extension/src/entrypoints/popup/pages/CredentialDetails.tsx index 4fd22bdcd..215cad198 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/CredentialDetails.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/CredentialDetails.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { @@ -9,7 +9,10 @@ import { AliasBlock, NotesBlock } from '@/entrypoints/popup/components/CredentialDetails'; +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 type { Credential } from '@/utils/shared/models/vault'; @@ -17,12 +20,14 @@ import type { Credential } from '@/utils/shared/models/vault'; /** * Credential details page. */ -const CredentialDetails: React.FC = () => { +const CredentialDetails: React.FC = (): React.ReactElement => { const { id } = useParams(); const navigate = useNavigate(); const dbContext = useDb(); const [credential, setCredential] = useState(null); const { setIsInitialLoading } = useLoading(); + const { setHeaderButtons } = useHeaderButtons(); + const [headerButtonsConfigured, setHeaderButtonsConfigured] = useState(false); /** * Check if the current page is an expanded popup. @@ -35,7 +40,7 @@ const CredentialDetails: React.FC = () => { /** * Open the credential details in a new expanded popup. */ - const openInNewPopup = (): void => { + const openInNewPopup = useCallback((): void => { const width = 380; const height = 600; const left = window.screen.width / 2 - width / 2; @@ -48,14 +53,28 @@ const CredentialDetails: React.FC = () => { ); window.close(); - }; + }, [id]); /** * Navigate to the edit page for this credential. */ - const handleEdit = (): void => { - navigate(`/credentials/${id}/edit`); - }; + const handleEdit = useCallback((): void => { + if (isPopup()) { + window.close(); + const width = 380; + const height = 600; + const left = window.screen.width / 2 - width / 2; + const top = window.screen.height / 2 - height / 2; + + window.open( + `popup.html?expanded=true#/credentials/${id}/edit`, + 'CredentialEdit', + `width=${width},height=${height},left=${left},top=${top},popup=true` + ); + } else { + navigate(`/credentials/${id}/edit`); + } + }, [id, navigate]); useEffect(() => { if (isPopup()) { @@ -81,20 +100,44 @@ const CredentialDetails: React.FC = () => { } }, [dbContext.sqliteClient, id, navigate, setIsInitialLoading]); + // Set header buttons on mount and clear on unmount + useEffect((): (() => void) => { + // Only set the header buttons once on mount. + if (!headerButtonsConfigured) { + const headerButtonsJSX = ( +
+ + +
+ ); + + setHeaderButtons(headerButtonsJSX); + setHeaderButtonsConfigured(true); + } + return () => {}; + }, [setHeaderButtons, headerButtonsConfigured, handleEdit, openInNewPopup]); + + // Clear header buttons on unmount + useEffect((): (() => void) => { + return () => setHeaderButtons(null); + }, [setHeaderButtons]); + if (!credential) { return
Loading...
; } return (
-
- - +
+
{credential.Alias?.Email && ( { +const EmailDetails: React.FC = (): React.ReactElement => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const dbContext = useDb(); @@ -24,6 +28,8 @@ const EmailDetails: React.FC = () => { const [email, setEmail] = useState(null); const [isLoading, setIsLoading] = useMinDurationLoading(true, 150); const { setIsInitialLoading } = useLoading(); + const { setHeaderButtons } = useHeaderButtons(); + const [headerButtonsConfigured, setHeaderButtonsConfigured] = useState(false); /** * Make sure the initial loading state is set to false when this component is loaded itself. @@ -73,14 +79,18 @@ const EmailDetails: React.FC = () => { /** * Handle deleting an email. */ - const handleDelete = async () : Promise => { + const handleDelete = useCallback(async () : Promise => { try { await webApi.delete(`Email/${id}`); - navigate('/emails'); + if (isPopup()) { + window.close(); + } else { + navigate('/emails'); + } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to delete email'); } - }; + }, [id, webApi, navigate]); /** * Check if the current page is an expanded popup. @@ -93,7 +103,7 @@ const EmailDetails: React.FC = () => { /** * Open the credential details in a new expanded popup. */ - const openInNewPopup = () : void => { + const openInNewPopup = useCallback((): void => { const width = 800; const height = 1000; const left = window.screen.width / 2 - width / 2; @@ -107,7 +117,7 @@ const EmailDetails: React.FC = () => { // Close the current tab window.close(); - }; + }, [id]); /** * Handle downloading an attachment. @@ -153,6 +163,37 @@ const EmailDetails: React.FC = () => { } }; + // Set header buttons on mount and clear on unmount + useEffect((): (() => void) => { + // Only set the header buttons once on mount. + if (!headerButtonsConfigured) { + const headerButtonsJSX = ( +
+ + +
+ ); + + setHeaderButtons(headerButtonsJSX); + setHeaderButtonsConfigured(true); + } + return () => {}; + }, [setHeaderButtons, headerButtonsConfigured, handleDelete, openInNewPopup]); + + // Clear header buttons on unmount + useEffect((): (() => void) => { + return () => setHeaderButtons(null); + }, [setHeaderButtons]); + if (isLoading) { return (
@@ -176,48 +217,6 @@ const EmailDetails: React.FC = () => {

{email.subject}

-
- - -

From: {email.fromDisplay} ({email.fromLocal}@{email.fromDomain})