Merge pull request 'Applied mailbox constraints' (#159) from finishline into main

Reviewed-on: #159
This commit was merged in pull request #159.
This commit is contained in:
2026-10-22 23:58:12 +02:00
4 changed files with 236 additions and 106 deletions
@@ -124,7 +124,7 @@ const navItems: NavItem[] = [
claim: 'tenant_management', claim: 'tenant_management',
}, },
{ {
name: 'Mailbox & Inbox', name: 'Inbox',
path: '/inbox', path: '/inbox',
icon: Inbox, icon: Inbox,
help: 'In-app notifications, communications, deliverable messages, drafts, and replies across active tenant users.', help: 'In-app notifications, communications, deliverable messages, drafts, and replies across active tenant users.',
@@ -55,6 +55,8 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
draftToEdit, draftToEdit,
}) => { }) => {
const { user, claims } = useAuth(); const { user, claims } = useAuth();
const currentUserId = String(user?.id || claims?.sub || '').trim().toLowerCase();
const currentUserEmail = String(user?.email || claims?.email || '').trim().toLowerCase();
const tenantName = const tenantName =
(claims?.tenant as string) || (claims?.tenant as string) ||
user?.tenant || user?.tenant ||
@@ -71,6 +73,15 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
const editorRef = useRef<HTMLDivElement>(null); const editorRef = useRef<HTMLDivElement>(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 => { const toInitialRichHtml = (raw: string): string => {
if (!raw || !raw.trim()) return ''; if (!raw || !raw.trim()) return '';
if (isHtmlContent(raw)) return raw; if (isHtmlContent(raw)) return raw;
@@ -83,23 +94,22 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
let initialHtml = ''; let initialHtml = '';
if (draftToEdit) { if (draftToEdit) {
setSelectedUsers( const candidate = draftToEdit.recipientId
draftToEdit.recipientId ? {
? [ id: draftToEdit.recipientId,
{ email: draftToEdit.recipientEmail,
id: draftToEdit.recipientId, name: draftToEdit.recipientName,
email: draftToEdit.recipientEmail, }
name: draftToEdit.recipientName, : undefined;
}, setSelectedUsers(candidate && !isCurrentUser(candidate) ? [candidate] : []);
]
: []
);
setSubject(draftToEdit.subject || ''); setSubject(draftToEdit.subject || '');
initialHtml = toInitialRichHtml(draftToEdit.message || ''); initialHtml = toInitialRichHtml(draftToEdit.message || '');
setMessage(initialHtml); setMessage(initialHtml);
setPlatform(draftToEdit.platform || NotificationPlatform.InApp); setPlatform(draftToEdit.platform || NotificationPlatform.InApp);
} else { } else {
setSelectedUsers(initialRecipient ? [initialRecipient] : []); setSelectedUsers(
initialRecipient && !isCurrentUser(initialRecipient) ? [initialRecipient] : []
);
setSubject(initialSubject || ''); setSubject(initialSubject || '');
initialHtml = toInitialRichHtml(initialMessage || ''); initialHtml = toInitialRichHtml(initialMessage || '');
setMessage(initialHtml); setMessage(initialHtml);
@@ -128,6 +138,10 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
if (!isOpen) return null; if (!isOpen) return null;
const handleSelectUser = (selected: SelectedUser) => { const handleSelectUser = (selected: SelectedUser) => {
if (isCurrentUser(selected)) {
setError('You cannot send a message to yourself.');
return;
}
setSelectedUsers([selected]); setSelectedUsers([selected]);
setError(null); setError(null);
}; };
@@ -220,6 +234,16 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
return; 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()) { if (!subject.trim()) {
setError('Please enter a subject'); setError('Please enter a subject');
return; return;
@@ -15,6 +15,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { getUsers } from '../../services/api'; import { getUsers } from '../../services/api';
import { User, ActivityStatus } from '../../types'; import { User, ActivityStatus } from '../../types';
import { useAuth } from '../../auth/AuthProvider';
export interface SelectedUser { export interface SelectedUser {
id: string; id: string;
@@ -43,9 +44,12 @@ export const UserSearchPicker: React.FC<UserSearchPickerProps> = ({
selectedUsers, selectedUsers,
onSelectUser, onSelectUser,
onRemoveUser, onRemoveUser,
multiple = false,
placeholder = 'Search by name, email, or role...', 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 [searchTerm, setSearchTerm] = useState('');
const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({}); const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
@@ -57,10 +61,17 @@ export const UserSearchPicker: React.FC<UserSearchPickerProps> = ({
staleTime: 5 * 60 * 1000, 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(() => { const activeUsers = useMemo(() => {
return users.filter(u => u.status === ActivityStatus.Active && !!u.email); return users.filter(u => {
}, [users]); 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 // Organize users into intuitive functional groups
const groupedUsers = useMemo(() => { const groupedUsers = useMemo(() => {
@@ -172,87 +183,83 @@ export const UserSearchPicker: React.FC<UserSearchPickerProps> = ({
.map(w => w.charAt(0).toUpperCase() + w.slice(1)) .map(w => w.charAt(0).toUpperCase() + w.slice(1))
.join(' '); .join(' ');
if (!multiple) { // Strictly single-recipient selection
// Single selection replaces onSelectUser({
onSelectUser({ id: u.id,
id: u.id, email: u.email,
email: u.email, name: formattedName,
name: formattedName, role: String(u.role ?? 'User'),
role: String(u.role ?? 'User'), });
}); setSearchTerm('');
setIsDropdownOpen(false); setIsDropdownOpen(false);
} else {
onSelectUser({
id: u.id,
email: u.email,
name: formattedName,
role: String(u.role ?? 'User'),
});
}
} }
}; };
const singleSelectedUser = selectedUsers[0];
return ( return (
<div className="relative w-full"> <div className="relative w-full">
{/* Selected Users Chips */} {/* Single Selected Recipient or Search Input Box */}
{selectedUsers.length > 0 && ( {singleSelectedUser ? (
<div className="flex flex-wrap gap-1.5 mb-2"> <div className="flex items-center justify-between px-3 py-2 rounded-xl bg-blue-500/10 border border-blue-500/30 text-blue-100 text-xs shadow-xs">
{selectedUsers.map(su => ( <div className="flex items-center gap-2.5 min-w-0">
<span <div className="w-6 h-6 rounded-full bg-blue-600/40 text-[10px] font-bold flex items-center justify-center text-blue-200 shrink-0 border border-blue-400/30">
key={su.id} {(singleSelectedUser.name || singleSelectedUser.email).slice(0, 2).toUpperCase()}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-blue-500/15 border border-blue-500/30 text-blue-200 text-xs font-medium shadow-xs" </div>
> <div className="flex items-center gap-2 min-w-0">
<div className="w-4 h-4 rounded-full bg-blue-600/40 text-[9px] font-bold flex items-center justify-center text-blue-300"> <span className="font-semibold text-white truncate">
{su.name ? su.name.slice(0, 2).toUpperCase() : su.email.slice(0, 2).toUpperCase()} {singleSelectedUser.name || singleSelectedUser.email}
</div> </span>
<span className="truncate max-w-[180px]">{su.name || su.email}</span> <span className="text-[11px] text-slate-400 font-mono truncate">
{su.role && ( &lt;{singleSelectedUser.email}&gt;
<span className="text-[10px] text-blue-400 bg-blue-900/40 px-1 py-0.2 rounded border border-blue-800/40"> </span>
{su.role} {singleSelectedUser.role && (
<span className="text-[10px] text-blue-300 bg-blue-900/40 px-1.5 py-0.5 rounded border border-blue-800/40 shrink-0">
{singleSelectedUser.role}
</span> </span>
)} )}
<button </div>
type="button" </div>
onClick={() => onRemoveUser(su.id)} <button
className="text-blue-400 hover:text-white hover:bg-blue-600/30 p-0.5 rounded transition cursor-pointer" type="button"
title="Remove recipient" onClick={() => onRemoveUser(singleSelectedUser.id)}
> className="flex items-center gap-1 text-[11px] text-blue-300 hover:text-white hover:bg-blue-600/30 px-2 py-1 rounded-lg transition cursor-pointer shrink-0"
<X className="w-3 h-3" /> title="Remove recipient to select another user"
</button> >
</span> <X className="w-3.5 h-3.5" />
))} <span>Change</span>
</button>
</div>
) : (
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-400">
<Search className="w-4 h-4" />
</div>
<input
type="text"
value={searchTerm}
onChange={e => {
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 && (
<button
type="button"
onClick={() => setSearchTerm('')}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400 hover:text-white"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div> </div>
)} )}
{/* Search Input Box */} {/* Organized User Directory Dropdown (only when no recipient is locked in) */}
<div className="relative"> {isDropdownOpen && !singleSelectedUser && (
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-400">
<Search className="w-4 h-4" />
</div>
<input
type="text"
value={searchTerm}
onChange={e => {
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 && (
<button
type="button"
onClick={() => setSearchTerm('')}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400 hover:text-white"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Organized User Directory Dropdown */}
{isDropdownOpen && (
<div className="absolute left-0 right-0 top-full mt-1.5 max-h-72 overflow-y-auto bg-[#0d1322] border border-slate-700/80 rounded-2xl shadow-2xl z-50 p-2 space-y-3 backdrop-blur-md animate-in fade-in slide-in-from-top-1"> <div className="absolute left-0 right-0 top-full mt-1.5 max-h-72 overflow-y-auto bg-[#0d1322] border border-slate-700/80 rounded-2xl shadow-2xl z-50 p-2 space-y-3 backdrop-blur-md animate-in fade-in slide-in-from-top-1">
{isLoading ? ( {isLoading ? (
<div className="py-6 text-center text-xs text-slate-400 flex items-center justify-center gap-2"> <div className="py-6 text-center text-xs text-slate-400 flex items-center justify-center gap-2">
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { import {
Inbox, Inbox,
Send, Send,
@@ -17,14 +18,16 @@ import {
RotateCcw, RotateCcw,
X, X,
Smartphone, Smartphone,
ShieldAlert,
} from 'lucide-react'; } from 'lucide-react';
import { useInAppNotifications, DraftMessage } from '../hooks/useInAppNotifications'; import { useInAppNotifications, DraftMessage } from '../hooks/useInAppNotifications';
import { ComposeMessageModal } from '../components/notifications/ComposeMessageModal'; import { ComposeMessageModal } from '../components/notifications/ComposeMessageModal';
import { MarkdownViewer } from '../components/notifications/MarkdownViewer'; import { MarkdownViewer } from '../components/notifications/MarkdownViewer';
import { formatDate } from '../lib/formatters'; import { formatDate } from '../lib/formatters';
import { Notification, NotificationPlatform, NotificationStatus } from '../types'; import { Notification, NotificationPlatform, NotificationStatus, User } from '../types';
import { useAuth } from '../auth/AuthProvider'; import { useAuth } from '../auth/AuthProvider';
import { stripMarkdown } from '../lib/markdownUtils'; import { stripMarkdown } from '../lib/markdownUtils';
import { getUsers } from '../services/api';
type MailboxFolder = 'inbox' | 'starred' | 'sent' | 'drafts' | 'trash'; type MailboxFolder = 'inbox' | 'starred' | 'sent' | 'drafts' | 'trash';
@@ -33,8 +36,15 @@ export const InboxPage: React.FC = () => {
const targetIdParam = searchParams.get('id'); const targetIdParam = searchParams.get('id');
const { user, claims } = useAuth(); const { user, claims } = useAuth();
const currentUserId = String(user?.id || claims?.sub || '').trim().toLowerCase();
const currentUserEmail = (user?.email || claims?.email || '') as string; const currentUserEmail = (user?.email || claims?.email || '') as string;
const { data: directoryUsers = [] } = useQuery<User[]>({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
});
const { const {
allMailboxNotifications, allMailboxNotifications,
inboxNotifications, inboxNotifications,
@@ -77,6 +87,8 @@ export const InboxPage: React.FC = () => {
// Inline quick reply state // Inline quick reply state
const [inlineReplyText, setInlineReplyText] = useState(''); const [inlineReplyText, setInlineReplyText] = useState('');
const [inlineReplySuccess, setInlineReplySuccess] = useState(false); const [inlineReplySuccess, setInlineReplySuccess] = useState(false);
// Self-reply prevention modal state
const [isSelfReplyModalOpen, setIsSelfReplyModalOpen] = useState(false);
// Active folder message items (always In-App notifications) // Active folder message items (always In-App notifications)
const folderMessages = useMemo(() => { const folderMessages = useMemo(() => {
@@ -162,15 +174,51 @@ export const InboxPage: React.FC = () => {
setIsComposeOpen(true); 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 // Open Compose Modal for a Reply
const handleReplyMessage = (msg: Notification) => { const handleReplyMessage = (msg: Notification) => {
const rawDest = msg.destination || ''; if (isNotificationFromSelf(msg)) {
let replyEmail = ''; setIsSelfReplyModalOpen(true);
if (rawDest.includes('From:')) { return;
const fromPart = rawDest.split('From:')[1]?.trim() || ''; }
replyEmail = fromPart.replace(/[<>]/g, '').trim();
} else if (rawDest.includes('@')) { const replyEmail = extractSenderEmail(msg);
replyEmail = rawDest.trim(); 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'}`; const replySubject = msg.subject?.startsWith('Re:') ? msg.subject : `Re: ${msg.subject || 'Communication'}`;
@@ -178,9 +226,9 @@ export const InboxPage: React.FC = () => {
setSelectedDraft(null); setSelectedDraft(null);
setComposeInitialRecipient( setComposeInitialRecipient(
replyEmail replyEmail && matchedSender
? { ? {
id: String(msg.recipient), id: String(matchedSender.id),
email: replyEmail, email: replyEmail,
name: replyEmail.split('@')[0], name: replyEmail.split('@')[0],
} }
@@ -214,18 +262,29 @@ export const InboxPage: React.FC = () => {
const handleSendInlineReply = async () => { const handleSendInlineReply = async () => {
if (!selectedMessage || !inlineReplyText.trim()) return; if (!selectedMessage || !inlineReplyText.trim()) return;
const rawDest = selectedMessage.destination || ''; if (isNotificationFromSelf(selectedMessage)) {
let replyEmail = ''; setIsSelfReplyModalOpen(true);
if (rawDest.includes('From:')) { return;
replyEmail = rawDest.split('From:')[1]?.trim().replace(/[<>]/g, '') || ''; }
} else if (rawDest.includes('@')) {
replyEmail = rawDest.trim(); 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 { try {
await sendMessage({ await sendMessage({
recipientId: String(selectedMessage.recipient), recipientId: targetRecipientId,
recipientEmail: replyEmail || currentUserEmail || 'team@dsi.gov.za', recipientEmail: replyEmail || 'team@dsi.gov.za',
subject: selectedMessage.subject?.startsWith('Re:') subject: selectedMessage.subject?.startsWith('Re:')
? selectedMessage.subject ? selectedMessage.subject
: `Re: ${selectedMessage.subject || 'Notification'}`, : `Re: ${selectedMessage.subject || 'Notification'}`,
@@ -874,6 +933,46 @@ export const InboxPage: React.FC = () => {
initialPlatform={composeInitialPlatform} initialPlatform={composeInitialPlatform}
draftToEdit={selectedDraft} draftToEdit={selectedDraft}
/> />
{/* Self-Reply Prevention Modal */}
{isSelfReplyModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm animate-in fade-in duration-150">
<div className="w-full max-w-md bg-[#0d1322] border border-rose-500/30 rounded-2xl shadow-2xl overflow-hidden p-6 space-y-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-rose-500/15 border border-rose-500/30 flex items-center justify-center text-rose-400 shrink-0">
<ShieldAlert className="w-5 h-5" />
</div>
<div>
<h3 className="text-sm font-bold text-white">Action Not Allowed</h3>
<p className="text-[11px] text-rose-300 font-medium">Self-Reply Restricted</p>
</div>
</div>
<button
type="button"
onClick={() => setIsSelfReplyModalOpen(false)}
className="p-1 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
<p className="text-xs text-slate-300 leading-relaxed">
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.
</p>
<div className="pt-2 border-t border-slate-800 flex justify-end gap-2">
<button
type="button"
onClick={() => setIsSelfReplyModalOpen(false)}
className="px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-md shadow-blue-600/25 transition cursor-pointer"
>
Understood
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
}; };