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:
@@ -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 && (
|
<{singleSelectedUser.email}>
|
||||||
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user