From 9ed210613ed7f3cbbe5d749afb649cbe3b662811 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 22 Oct 2026 23:57:38 +0200 Subject: [PATCH] Applied mailbox constraints --- .../src/components/layout/AppLayout.tsx | 2 +- .../notifications/ComposeMessageModal.tsx | 48 ++++-- .../notifications/UserSearchPicker.tsx | 157 +++++++++--------- .../src/pages/InboxPage.tsx | 135 +++++++++++++-- 4 files changed, 236 insertions(+), 106 deletions(-) diff --git a/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx b/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx index e8d4c9a..d2dc1a2 100644 --- a/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx +++ b/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx @@ -124,7 +124,7 @@ const navItems: NavItem[] = [ claim: 'tenant_management', }, { - name: 'Mailbox & Inbox', + name: 'Inbox', path: '/inbox', icon: Inbox, help: 'In-app notifications, communications, deliverable messages, drafts, and replies across active tenant users.', diff --git a/PostFundManagement.ConsoleUI/src/components/notifications/ComposeMessageModal.tsx b/PostFundManagement.ConsoleUI/src/components/notifications/ComposeMessageModal.tsx index 83c0672..b4e5a84 100644 --- a/PostFundManagement.ConsoleUI/src/components/notifications/ComposeMessageModal.tsx +++ b/PostFundManagement.ConsoleUI/src/components/notifications/ComposeMessageModal.tsx @@ -55,6 +55,8 @@ export const ComposeMessageModal: React.FC = ({ draftToEdit, }) => { const { user, claims } = useAuth(); + const currentUserId = String(user?.id || claims?.sub || '').trim().toLowerCase(); + const currentUserEmail = String(user?.email || claims?.email || '').trim().toLowerCase(); const tenantName = (claims?.tenant as string) || user?.tenant || @@ -71,6 +73,15 @@ export const ComposeMessageModal: React.FC = ({ const editorRef = useRef(null); + const isCurrentUser = (candidate?: { id?: string; email?: string }) => { + if (!candidate) return false; + const cid = String(candidate.id || '').trim().toLowerCase(); + const cemail = String(candidate.email || '').trim().toLowerCase(); + if (currentUserId && cid === currentUserId) return true; + if (currentUserEmail && cemail === currentUserEmail) return true; + return false; + }; + const toInitialRichHtml = (raw: string): string => { if (!raw || !raw.trim()) return ''; if (isHtmlContent(raw)) return raw; @@ -83,23 +94,22 @@ export const ComposeMessageModal: React.FC = ({ let initialHtml = ''; if (draftToEdit) { - setSelectedUsers( - draftToEdit.recipientId - ? [ - { - id: draftToEdit.recipientId, - email: draftToEdit.recipientEmail, - name: draftToEdit.recipientName, - }, - ] - : [] - ); + const candidate = draftToEdit.recipientId + ? { + id: draftToEdit.recipientId, + email: draftToEdit.recipientEmail, + name: draftToEdit.recipientName, + } + : undefined; + setSelectedUsers(candidate && !isCurrentUser(candidate) ? [candidate] : []); setSubject(draftToEdit.subject || ''); initialHtml = toInitialRichHtml(draftToEdit.message || ''); setMessage(initialHtml); setPlatform(draftToEdit.platform || NotificationPlatform.InApp); } else { - setSelectedUsers(initialRecipient ? [initialRecipient] : []); + setSelectedUsers( + initialRecipient && !isCurrentUser(initialRecipient) ? [initialRecipient] : [] + ); setSubject(initialSubject || ''); initialHtml = toInitialRichHtml(initialMessage || ''); setMessage(initialHtml); @@ -128,6 +138,10 @@ export const ComposeMessageModal: React.FC = ({ if (!isOpen) return null; const handleSelectUser = (selected: SelectedUser) => { + if (isCurrentUser(selected)) { + setError('You cannot send a message to yourself.'); + return; + } setSelectedUsers([selected]); setError(null); }; @@ -220,6 +234,16 @@ export const ComposeMessageModal: React.FC = ({ return; } + if (selectedUsers.length > 1) { + setError('Only one recipient is allowed per message'); + return; + } + + if (isCurrentUser(selectedUsers[0])) { + setError('You cannot send a message to yourself'); + return; + } + if (!subject.trim()) { setError('Please enter a subject'); return; diff --git a/PostFundManagement.ConsoleUI/src/components/notifications/UserSearchPicker.tsx b/PostFundManagement.ConsoleUI/src/components/notifications/UserSearchPicker.tsx index d5b77d2..8048e40 100644 --- a/PostFundManagement.ConsoleUI/src/components/notifications/UserSearchPicker.tsx +++ b/PostFundManagement.ConsoleUI/src/components/notifications/UserSearchPicker.tsx @@ -15,6 +15,7 @@ import { } from 'lucide-react'; import { getUsers } from '../../services/api'; import { User, ActivityStatus } from '../../types'; +import { useAuth } from '../../auth/AuthProvider'; export interface SelectedUser { id: string; @@ -43,9 +44,12 @@ export const UserSearchPicker: React.FC = ({ selectedUsers, onSelectUser, onRemoveUser, - multiple = false, placeholder = 'Search by name, email, or role...', }) => { + const { user, claims } = useAuth(); + const currentUserId = String(user?.id || claims?.sub || '').trim().toLowerCase(); + const currentUserEmail = String(user?.email || claims?.email || '').trim().toLowerCase(); + const [searchTerm, setSearchTerm] = useState(''); const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [collapsedGroups, setCollapsedGroups] = useState>({}); @@ -57,10 +61,17 @@ export const UserSearchPicker: React.FC = ({ staleTime: 5 * 60 * 1000, }); - // Filter active users + // Filter active users and exclude the current logged-in user so they can never message themselves const activeUsers = useMemo(() => { - return users.filter(u => u.status === ActivityStatus.Active && !!u.email); - }, [users]); + return users.filter(u => { + if (u.status !== ActivityStatus.Active || !u.email) return false; + const uid = String(u.id || '').trim().toLowerCase(); + const uemail = u.email.trim().toLowerCase(); + if (currentUserId && uid === currentUserId) return false; + if (currentUserEmail && uemail === currentUserEmail) return false; + return true; + }); + }, [users, currentUserId, currentUserEmail]); // Organize users into intuitive functional groups const groupedUsers = useMemo(() => { @@ -172,87 +183,83 @@ export const UserSearchPicker: React.FC = ({ .map(w => w.charAt(0).toUpperCase() + w.slice(1)) .join(' '); - if (!multiple) { - // Single selection replaces - onSelectUser({ - id: u.id, - email: u.email, - name: formattedName, - role: String(u.role ?? 'User'), - }); - setIsDropdownOpen(false); - } else { - onSelectUser({ - id: u.id, - email: u.email, - name: formattedName, - role: String(u.role ?? 'User'), - }); - } + // Strictly single-recipient selection + onSelectUser({ + id: u.id, + email: u.email, + name: formattedName, + role: String(u.role ?? 'User'), + }); + setSearchTerm(''); + setIsDropdownOpen(false); } }; + const singleSelectedUser = selectedUsers[0]; + return (
- {/* Selected Users Chips */} - {selectedUsers.length > 0 && ( -
- {selectedUsers.map(su => ( - -
- {su.name ? su.name.slice(0, 2).toUpperCase() : su.email.slice(0, 2).toUpperCase()} -
- {su.name || su.email} - {su.role && ( - - {su.role} + {/* Single Selected Recipient or Search Input Box */} + {singleSelectedUser ? ( +
+
+
+ {(singleSelectedUser.name || singleSelectedUser.email).slice(0, 2).toUpperCase()} +
+
+ + {singleSelectedUser.name || singleSelectedUser.email} + + + <{singleSelectedUser.email}> + + {singleSelectedUser.role && ( + + {singleSelectedUser.role} )} - - - ))} +
+
+ +
+ ) : ( +
+
+ +
+ { + setSearchTerm(e.target.value); + if (!isDropdownOpen) setIsDropdownOpen(true); + }} + onFocus={() => setIsDropdownOpen(true)} + placeholder={placeholder} + className="w-full pl-9 pr-8 py-2 rounded-xl bg-slate-900/90 border border-slate-700/80 hover:border-slate-600 focus:border-blue-500 text-slate-100 placeholder-slate-400 text-xs focus:outline-none transition shadow-inner" + /> + {searchTerm && ( + + )}
)} - {/* Search Input Box */} -
-
- -
- { - setSearchTerm(e.target.value); - if (!isDropdownOpen) setIsDropdownOpen(true); - }} - onFocus={() => setIsDropdownOpen(true)} - placeholder={placeholder} - className="w-full pl-9 pr-8 py-2 rounded-xl bg-slate-900/90 border border-slate-700/80 hover:border-slate-600 focus:border-blue-500 text-slate-100 placeholder-slate-400 text-xs focus:outline-none transition shadow-inner" - /> - {searchTerm && ( - - )} -
- - {/* Organized User Directory Dropdown */} - {isDropdownOpen && ( + {/* Organized User Directory Dropdown (only when no recipient is locked in) */} + {isDropdownOpen && !singleSelectedUser && (
{isLoading ? (
diff --git a/PostFundManagement.ConsoleUI/src/pages/InboxPage.tsx b/PostFundManagement.ConsoleUI/src/pages/InboxPage.tsx index f0556ef..03aac2e 100644 --- a/PostFundManagement.ConsoleUI/src/pages/InboxPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/InboxPage.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useMemo } from 'react'; import { useSearchParams } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; import { Inbox, Send, @@ -17,14 +18,16 @@ import { RotateCcw, X, Smartphone, + ShieldAlert, } from 'lucide-react'; import { useInAppNotifications, DraftMessage } from '../hooks/useInAppNotifications'; import { ComposeMessageModal } from '../components/notifications/ComposeMessageModal'; import { MarkdownViewer } from '../components/notifications/MarkdownViewer'; import { formatDate } from '../lib/formatters'; -import { Notification, NotificationPlatform, NotificationStatus } from '../types'; +import { Notification, NotificationPlatform, NotificationStatus, User } from '../types'; import { useAuth } from '../auth/AuthProvider'; import { stripMarkdown } from '../lib/markdownUtils'; +import { getUsers } from '../services/api'; type MailboxFolder = 'inbox' | 'starred' | 'sent' | 'drafts' | 'trash'; @@ -33,8 +36,15 @@ export const InboxPage: React.FC = () => { const targetIdParam = searchParams.get('id'); const { user, claims } = useAuth(); + const currentUserId = String(user?.id || claims?.sub || '').trim().toLowerCase(); const currentUserEmail = (user?.email || claims?.email || '') as string; + const { data: directoryUsers = [] } = useQuery({ + queryKey: ['users-directory'], + queryFn: () => getUsers(1, 200), + staleTime: 5 * 60 * 1000, + }); + const { allMailboxNotifications, inboxNotifications, @@ -77,6 +87,8 @@ export const InboxPage: React.FC = () => { // Inline quick reply state const [inlineReplyText, setInlineReplyText] = useState(''); const [inlineReplySuccess, setInlineReplySuccess] = useState(false); + // Self-reply prevention modal state + const [isSelfReplyModalOpen, setIsSelfReplyModalOpen] = useState(false); // Active folder message items (always In-App notifications) const folderMessages = useMemo(() => { @@ -162,15 +174,51 @@ export const InboxPage: React.FC = () => { setIsComposeOpen(true); }; + const extractSenderEmail = (msg: Notification): string => { + const rawDest = msg.destination || ''; + if (rawDest.includes('From:')) { + const fromPart = rawDest.split('From:')[1]?.split('|')[0]?.trim() || ''; + return fromPart.replace(/[<>]/g, '').trim(); + } + if (!rawDest.includes('To:') && rawDest.includes('@')) { + return rawDest.trim(); + } + return ''; + }; + + const isNotificationFromSelf = (msg: Notification): boolean => { + if (activeFolder === 'sent' || String(msg.id).startsWith('sent_')) { + return true; + } + const senderEmail = extractSenderEmail(msg).toLowerCase(); + const myEmail = currentUserEmail.trim().toLowerCase(); + if (myEmail && senderEmail && senderEmail === myEmail) { + return true; + } + if (!senderEmail && String(msg.recipient || '').trim().toLowerCase() === currentUserId) { + return true; + } + return false; + }; + // Open Compose Modal for a Reply const handleReplyMessage = (msg: Notification) => { - const rawDest = msg.destination || ''; - let replyEmail = ''; - if (rawDest.includes('From:')) { - const fromPart = rawDest.split('From:')[1]?.trim() || ''; - replyEmail = fromPart.replace(/[<>]/g, '').trim(); - } else if (rawDest.includes('@')) { - replyEmail = rawDest.trim(); + if (isNotificationFromSelf(msg)) { + setIsSelfReplyModalOpen(true); + return; + } + + const replyEmail = extractSenderEmail(msg); + const matchedSender = replyEmail + ? directoryUsers.find(u => (u.email || '').trim().toLowerCase() === replyEmail.toLowerCase()) + : undefined; + + if ( + (matchedSender && String(matchedSender.id).trim().toLowerCase() === currentUserId) || + (replyEmail && replyEmail.toLowerCase() === currentUserEmail.trim().toLowerCase()) + ) { + setIsSelfReplyModalOpen(true); + return; } const replySubject = msg.subject?.startsWith('Re:') ? msg.subject : `Re: ${msg.subject || 'Communication'}`; @@ -178,9 +226,9 @@ export const InboxPage: React.FC = () => { setSelectedDraft(null); setComposeInitialRecipient( - replyEmail + replyEmail && matchedSender ? { - id: String(msg.recipient), + id: String(matchedSender.id), email: replyEmail, name: replyEmail.split('@')[0], } @@ -214,18 +262,29 @@ export const InboxPage: React.FC = () => { const handleSendInlineReply = async () => { if (!selectedMessage || !inlineReplyText.trim()) return; - const rawDest = selectedMessage.destination || ''; - let replyEmail = ''; - if (rawDest.includes('From:')) { - replyEmail = rawDest.split('From:')[1]?.trim().replace(/[<>]/g, '') || ''; - } else if (rawDest.includes('@')) { - replyEmail = rawDest.trim(); + if (isNotificationFromSelf(selectedMessage)) { + setIsSelfReplyModalOpen(true); + return; + } + + const replyEmail = extractSenderEmail(selectedMessage); + const matchedSender = replyEmail + ? directoryUsers.find(u => (u.email || '').trim().toLowerCase() === replyEmail.toLowerCase()) + : undefined; + + const targetRecipientId = matchedSender ? String(matchedSender.id) : String(selectedMessage.recipient); + if ( + targetRecipientId.trim().toLowerCase() === currentUserId || + (replyEmail && replyEmail.toLowerCase() === currentUserEmail.trim().toLowerCase()) + ) { + setIsSelfReplyModalOpen(true); + return; } try { await sendMessage({ - recipientId: String(selectedMessage.recipient), - recipientEmail: replyEmail || currentUserEmail || 'team@dsi.gov.za', + recipientId: targetRecipientId, + recipientEmail: replyEmail || 'team@dsi.gov.za', subject: selectedMessage.subject?.startsWith('Re:') ? selectedMessage.subject : `Re: ${selectedMessage.subject || 'Notification'}`, @@ -874,6 +933,46 @@ export const InboxPage: React.FC = () => { initialPlatform={composeInitialPlatform} draftToEdit={selectedDraft} /> + + {/* Self-Reply Prevention Modal */} + {isSelfReplyModalOpen && ( +
+
+
+
+
+ +
+
+

Action Not Allowed

+

Self-Reply Restricted

+
+
+ +
+ +

+ You cannot reply to a notification that originated from your own account. Please compose a new message and select another recipient from the directory if you wish to follow up with a colleague. +

+ +
+ +
+
+
+ )}
); };