Applied mailbox constraints

This commit is contained in:
2026-10-22 23:57:38 +02:00
parent 5050c901bc
commit 9ed210613e
4 changed files with 236 additions and 106 deletions
@@ -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.',
@@ -55,6 +55,8 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
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<ComposeMessageModalProps> = ({
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 => {
if (!raw || !raw.trim()) return '';
if (isHtmlContent(raw)) return raw;
@@ -83,23 +94,22 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
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<ComposeMessageModalProps> = ({
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<ComposeMessageModalProps> = ({
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;
@@ -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<UserSearchPickerProps> = ({
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<Record<string, boolean>>({});
@@ -57,10 +61,17 @@ export const UserSearchPicker: React.FC<UserSearchPickerProps> = ({
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<UserSearchPickerProps> = ({
.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 (
<div className="relative w-full">
{/* Selected Users Chips */}
{selectedUsers.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{selectedUsers.map(su => (
<span
key={su.id}
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 className="w-4 h-4 rounded-full bg-blue-600/40 text-[9px] font-bold flex items-center justify-center text-blue-300">
{su.name ? su.name.slice(0, 2).toUpperCase() : su.email.slice(0, 2).toUpperCase()}
</div>
<span className="truncate max-w-[180px]">{su.name || su.email}</span>
{su.role && (
<span className="text-[10px] text-blue-400 bg-blue-900/40 px-1 py-0.2 rounded border border-blue-800/40">
{su.role}
{/* Single Selected Recipient or Search Input Box */}
{singleSelectedUser ? (
<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">
<div className="flex items-center gap-2.5 min-w-0">
<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">
{(singleSelectedUser.name || singleSelectedUser.email).slice(0, 2).toUpperCase()}
</div>
<div className="flex items-center gap-2 min-w-0">
<span className="font-semibold text-white truncate">
{singleSelectedUser.name || singleSelectedUser.email}
</span>
<span className="text-[11px] text-slate-400 font-mono truncate">
&lt;{singleSelectedUser.email}&gt;
</span>
{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>
)}
<button
type="button"
onClick={() => onRemoveUser(su.id)}
className="text-blue-400 hover:text-white hover:bg-blue-600/30 p-0.5 rounded transition cursor-pointer"
title="Remove recipient"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
</div>
<button
type="button"
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"
title="Remove recipient to select another user"
>
<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>
)}
{/* Search Input Box */}
<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>
{/* Organized User Directory Dropdown */}
{isDropdownOpen && (
{/* Organized User Directory Dropdown (only when no recipient is locked in) */}
{isDropdownOpen && !singleSelectedUser && (
<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 ? (
<div className="py-6 text-center text-xs text-slate-400 flex items-center justify-center gap-2">