feat: Multi-email ticketing system with platform email addresses
- Add PlatformEmailAddress model for managing platform-level email addresses - Add TicketEmailAddress model for tenant-level email addresses - Create MailServerService for IMAP integration with mail.talova.net - Implement PlatformEmailReceiver for processing incoming platform emails - Add email autoconfiguration for Mozilla, Microsoft, and Apple clients - Add configurable email polling interval in platform settings - Add "Check Emails" button on support page for manual refresh - Add ticket counts to status tabs on support page - Add platform email addresses management page - Add Privacy Policy and Terms of Service pages - Add robots.txt for SEO - Restrict email addresses to smoothschedule.com domain only 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
775
frontend/src/components/PlatformEmailAddressManager.tsx
Normal file
775
frontend/src/components/PlatformEmailAddressManager.tsx
Normal file
@@ -0,0 +1,775 @@
|
||||
/**
|
||||
* Platform Email Address Manager Component
|
||||
* Manages email addresses hosted on mail.talova.net via SSH
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Mail,
|
||||
Plus,
|
||||
Trash2,
|
||||
Edit,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Star,
|
||||
TestTube,
|
||||
RefreshCw,
|
||||
Server,
|
||||
AlertTriangle,
|
||||
X,
|
||||
Download,
|
||||
Unlink,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
usePlatformEmailAddresses,
|
||||
useDeletePlatformEmailAddress,
|
||||
useRemoveLocalPlatformEmailAddress,
|
||||
useTestImapConnection,
|
||||
useTestSmtpConnection,
|
||||
useSyncPlatformEmailAddress,
|
||||
useSetAsDefault,
|
||||
useTestMailServerConnection,
|
||||
useCreatePlatformEmailAddress,
|
||||
useUpdatePlatformEmailAddress,
|
||||
useAssignableUsers,
|
||||
useImportFromMailServer,
|
||||
PlatformEmailAddressListItem,
|
||||
} from '../hooks/usePlatformEmailAddresses';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
// Color options for email addresses
|
||||
const COLOR_OPTIONS = [
|
||||
'#3b82f6', // blue
|
||||
'#10b981', // green
|
||||
'#f59e0b', // amber
|
||||
'#ef4444', // red
|
||||
'#8b5cf6', // violet
|
||||
'#ec4899', // pink
|
||||
'#06b6d4', // cyan
|
||||
'#f97316', // orange
|
||||
];
|
||||
|
||||
interface EmailAddressFormData {
|
||||
display_name: string;
|
||||
sender_name: string;
|
||||
assigned_user_id: number | null;
|
||||
local_part: string;
|
||||
domain: string;
|
||||
color: string;
|
||||
password: string;
|
||||
is_active: boolean;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
interface ConfirmModalState {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText: string;
|
||||
confirmStyle: 'danger' | 'warning';
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
const PlatformEmailAddressManager: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingAddress, setEditingAddress] = useState<PlatformEmailAddressListItem | null>(null);
|
||||
const [confirmModal, setConfirmModal] = useState<ConfirmModalState>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
confirmText: 'Confirm',
|
||||
confirmStyle: 'danger',
|
||||
onConfirm: () => {},
|
||||
});
|
||||
const [formData, setFormData] = useState<EmailAddressFormData>({
|
||||
display_name: '',
|
||||
sender_name: '',
|
||||
assigned_user_id: null,
|
||||
local_part: '',
|
||||
domain: 'smoothschedule.com',
|
||||
color: '#3b82f6',
|
||||
password: '',
|
||||
is_active: true,
|
||||
is_default: false,
|
||||
});
|
||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const { data: emailAddresses = [], isLoading } = usePlatformEmailAddresses();
|
||||
const { data: usersData } = useAssignableUsers();
|
||||
const deleteAddress = useDeletePlatformEmailAddress();
|
||||
const removeLocal = useRemoveLocalPlatformEmailAddress();
|
||||
const testImap = useTestImapConnection();
|
||||
const testSmtp = useTestSmtpConnection();
|
||||
const syncAddress = useSyncPlatformEmailAddress();
|
||||
const setDefault = useSetAsDefault();
|
||||
const testMailServer = useTestMailServerConnection();
|
||||
const createAddress = useCreatePlatformEmailAddress();
|
||||
const updateAddress = useUpdatePlatformEmailAddress();
|
||||
const importFromServer = useImportFromMailServer();
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingAddress(null);
|
||||
setFormData({
|
||||
display_name: '',
|
||||
sender_name: '',
|
||||
assigned_user_id: null,
|
||||
local_part: '',
|
||||
domain: 'smoothschedule.com',
|
||||
color: '#3b82f6',
|
||||
password: '',
|
||||
is_active: true,
|
||||
is_default: false,
|
||||
});
|
||||
setFormErrors({});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (address: PlatformEmailAddressListItem) => {
|
||||
setEditingAddress(address);
|
||||
setFormData({
|
||||
display_name: address.display_name,
|
||||
sender_name: address.sender_name || '',
|
||||
assigned_user_id: address.assigned_user?.id || null,
|
||||
local_part: address.local_part,
|
||||
domain: address.domain,
|
||||
color: address.color,
|
||||
password: '',
|
||||
is_active: address.is_active,
|
||||
is_default: address.is_default,
|
||||
});
|
||||
setFormErrors({});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingAddress(null);
|
||||
setFormErrors({});
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!formData.display_name.trim()) {
|
||||
errors.display_name = 'Display name is required';
|
||||
}
|
||||
|
||||
if (!editingAddress && !formData.local_part.trim()) {
|
||||
errors.local_part = 'Email local part is required';
|
||||
} else if (!editingAddress && !/^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/i.test(formData.local_part)) {
|
||||
errors.local_part = 'Invalid email format';
|
||||
}
|
||||
|
||||
if (!editingAddress && !formData.password) {
|
||||
errors.password = 'Password is required';
|
||||
} else if (formData.password && formData.password.length < 8) {
|
||||
errors.password = 'Password must be at least 8 characters';
|
||||
}
|
||||
|
||||
setFormErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingAddress) {
|
||||
// Update existing address
|
||||
const updateData: any = {
|
||||
display_name: formData.display_name,
|
||||
sender_name: formData.sender_name,
|
||||
assigned_user_id: formData.assigned_user_id,
|
||||
color: formData.color,
|
||||
is_active: formData.is_active,
|
||||
is_default: formData.is_default,
|
||||
};
|
||||
if (formData.password) {
|
||||
updateData.password = formData.password;
|
||||
}
|
||||
|
||||
await updateAddress.mutateAsync({
|
||||
id: editingAddress.id,
|
||||
data: updateData,
|
||||
});
|
||||
toast.success('Email address updated successfully');
|
||||
} else {
|
||||
// Create new address
|
||||
await createAddress.mutateAsync({
|
||||
display_name: formData.display_name,
|
||||
sender_name: formData.sender_name,
|
||||
assigned_user_id: formData.assigned_user_id,
|
||||
local_part: formData.local_part.toLowerCase(),
|
||||
domain: formData.domain,
|
||||
color: formData.color,
|
||||
password: formData.password,
|
||||
is_active: formData.is_active,
|
||||
is_default: formData.is_default,
|
||||
});
|
||||
toast.success('Email address created and synced to mail server');
|
||||
}
|
||||
handleCloseModal();
|
||||
} catch (error: any) {
|
||||
const errorMessage =
|
||||
error.response?.data?.mail_server ||
|
||||
error.response?.data?.local_part ||
|
||||
error.response?.data?.detail ||
|
||||
'Failed to save email address';
|
||||
toast.error(Array.isArray(errorMessage) ? errorMessage[0] : errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: number, displayName: string) => {
|
||||
setConfirmModal({
|
||||
isOpen: true,
|
||||
title: 'Delete Email Address',
|
||||
message: `Are you sure you want to delete "${displayName}"? This will permanently remove the account from both the database and the mail server. This action cannot be undone.`,
|
||||
confirmText: 'Delete',
|
||||
confirmStyle: 'danger',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await deleteAddress.mutateAsync(id);
|
||||
toast.success(`${displayName} deleted successfully`);
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete email address');
|
||||
}
|
||||
setConfirmModal(prev => ({ ...prev, isOpen: false }));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveLocal = (id: number, displayName: string) => {
|
||||
setConfirmModal({
|
||||
isOpen: true,
|
||||
title: 'Remove from Database',
|
||||
message: `Remove "${displayName}" from the database? The email account will remain active on the mail server and can be re-imported later.`,
|
||||
confirmText: 'Remove',
|
||||
confirmStyle: 'warning',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
const result = await removeLocal.mutateAsync(id);
|
||||
toast.success(result.message);
|
||||
} catch (error) {
|
||||
toast.error('Failed to remove email address');
|
||||
}
|
||||
setConfirmModal(prev => ({ ...prev, isOpen: false }));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const closeConfirmModal = () => {
|
||||
setConfirmModal(prev => ({ ...prev, isOpen: false }));
|
||||
};
|
||||
|
||||
const handleTestImap = async (id: number, displayName: string) => {
|
||||
toast.loading(`Testing IMAP connection for ${displayName}...`, { id: `imap-${id}` });
|
||||
try {
|
||||
const result = await testImap.mutateAsync(id);
|
||||
if (result.success) {
|
||||
toast.success(result.message, { id: `imap-${id}` });
|
||||
} else {
|
||||
toast.error(result.message, { id: `imap-${id}` });
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'IMAP test failed', { id: `imap-${id}` });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestSmtp = async (id: number, displayName: string) => {
|
||||
toast.loading(`Testing SMTP connection for ${displayName}...`, { id: `smtp-${id}` });
|
||||
try {
|
||||
const result = await testSmtp.mutateAsync(id);
|
||||
if (result.success) {
|
||||
toast.success(result.message, { id: `smtp-${id}` });
|
||||
} else {
|
||||
toast.error(result.message, { id: `smtp-${id}` });
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'SMTP test failed', { id: `smtp-${id}` });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async (id: number, displayName: string) => {
|
||||
toast.loading(`Syncing ${displayName} to mail server...`, { id: `sync-${id}` });
|
||||
try {
|
||||
const result = await syncAddress.mutateAsync(id);
|
||||
if (result.success) {
|
||||
toast.success(result.message, { id: `sync-${id}` });
|
||||
} else {
|
||||
toast.error(result.message, { id: `sync-${id}` });
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Sync failed', { id: `sync-${id}` });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefault = async (id: number, displayName: string) => {
|
||||
try {
|
||||
const result = await setDefault.mutateAsync(id);
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to set as default');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestMailServer = async () => {
|
||||
toast.loading('Testing connection to mail server...', { id: 'mail-server-test' });
|
||||
try {
|
||||
const result = await testMailServer.mutateAsync();
|
||||
if (result.success) {
|
||||
toast.success(result.message, { id: 'mail-server-test' });
|
||||
} else {
|
||||
toast.error(result.message, { id: 'mail-server-test' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Connection test failed', { id: 'mail-server-test' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportFromServer = async () => {
|
||||
toast.loading('Importing email addresses from mail server...', { id: 'import-emails' });
|
||||
try {
|
||||
const result = await importFromServer.mutateAsync();
|
||||
if (result.success) {
|
||||
if (result.imported_count > 0) {
|
||||
toast.success(result.message, { id: 'import-emails' });
|
||||
} else {
|
||||
toast.success('No new email addresses to import', { id: 'import-emails' });
|
||||
}
|
||||
} else {
|
||||
toast.error('Import failed', { id: 'import-emails' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Import failed', { id: 'import-emails' });
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Email addresses are managed directly on the mail server (mail.talova.net)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleTestMailServer}
|
||||
disabled={testMailServer.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<Server className="w-4 h-4" />
|
||||
Test Mail Server
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImportFromServer}
|
||||
disabled={importFromServer.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
{importFromServer.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="w-4 h-4" />
|
||||
)}
|
||||
Import from Server
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Email Address
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email Addresses List */}
|
||||
{emailAddresses.length === 0 ? (
|
||||
<div className="text-center py-12 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<Mail className="w-16 h-16 mx-auto text-gray-400 dark:text-gray-600 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
No email addresses configured
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Add your first platform email address to start receiving support tickets
|
||||
</p>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Email Address
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{emailAddresses.map((address) => (
|
||||
<div
|
||||
key={address.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6"
|
||||
style={{ borderLeft: `4px solid ${address.color}` }}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{address.display_name}
|
||||
</h3>
|
||||
{address.is_default && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">
|
||||
<Star className="w-3 h-3" />
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
{address.is_active ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Active
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
|
||||
<XCircle className="w-3 h-3" />
|
||||
Inactive
|
||||
</span>
|
||||
)}
|
||||
{address.mail_server_synced ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||
<Server className="w-3 h-3" />
|
||||
Synced
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Not Synced
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-3">{address.email_address}</p>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>
|
||||
Processed: <strong>{address.emails_processed_count}</strong> emails
|
||||
</span>
|
||||
{address.last_check_at && (
|
||||
<span>
|
||||
Last checked: {new Date(address.last_check_at).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!address.is_default && (
|
||||
<button
|
||||
onClick={() => handleSetDefault(address.id, address.display_name)}
|
||||
disabled={setDefault.isPending}
|
||||
className="p-2 text-gray-600 hover:text-yellow-600 dark:text-gray-400 dark:hover:text-yellow-400 transition-colors"
|
||||
title="Set as default"
|
||||
>
|
||||
<Star className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleSync(address.id, address.display_name)}
|
||||
disabled={syncAddress.isPending}
|
||||
className="p-2 text-gray-600 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400 transition-colors"
|
||||
title="Sync to mail server"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTestImap(address.id, address.display_name)}
|
||||
disabled={testImap.isPending}
|
||||
className="p-2 text-gray-600 hover:text-green-600 dark:text-gray-400 dark:hover:text-green-400 transition-colors"
|
||||
title="Test IMAP"
|
||||
>
|
||||
<TestTube className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(address)}
|
||||
className="p-2 text-gray-600 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400 transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemoveLocal(address.id, address.display_name)}
|
||||
disabled={removeLocal.isPending}
|
||||
className="p-2 text-gray-600 hover:text-orange-600 dark:text-gray-400 dark:hover:text-orange-400 transition-colors"
|
||||
title="Remove from database (keep on mail server)"
|
||||
>
|
||||
<Unlink className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(address.id, address.display_name)}
|
||||
disabled={deleteAddress.isPending}
|
||||
className="p-2 text-gray-600 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400 transition-colors"
|
||||
title="Delete (also removes from mail server)"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg mx-4">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{editingAddress ? 'Edit Email Address' : 'Add Email Address'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleCloseModal}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||
{/* Display Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.display_name}
|
||||
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })}
|
||||
placeholder="e.g., Support, Billing, Sales"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
{formErrors.display_name && (
|
||||
<p className="mt-1 text-sm text-red-500">{formErrors.display_name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sender Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Sender Name <span className="text-gray-400 font-normal">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.sender_name}
|
||||
onChange={(e) => setFormData({ ...formData, sender_name: e.target.value })}
|
||||
placeholder="e.g., SmoothSchedule Support Team"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Name shown in the From field of outgoing emails. If blank, uses Display Name.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Assigned User */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Assigned User <span className="text-gray-400 font-normal">(optional)</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.assigned_user_id || ''}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
assigned_user_id: e.target.value ? Number(e.target.value) : null
|
||||
})}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">No user assigned</option>
|
||||
{usersData?.users?.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.full_name} ({user.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
If assigned, the user's name will be used as the sender name in outgoing emails.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Email Address (only show for new addresses) */}
|
||||
{!editingAddress && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.local_part}
|
||||
onChange={(e) => setFormData({ ...formData, local_part: e.target.value.toLowerCase() })}
|
||||
placeholder="support"
|
||||
className="flex-1 min-w-0 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<span className="flex-shrink-0 text-gray-500 dark:text-gray-400 font-medium">
|
||||
@smoothschedule.com
|
||||
</span>
|
||||
</div>
|
||||
{formErrors.local_part && (
|
||||
<p className="mt-1 text-sm text-red-500">{formErrors.local_part}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{editingAddress ? 'New Password (leave blank to keep current)' : 'Password'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder={editingAddress ? 'Leave blank to keep current' : 'Enter password'}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
{formErrors.password && (
|
||||
<p className="mt-1 text-sm text-red-500">{formErrors.password}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Minimum 8 characters. This password will be synced to the mail server.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Color */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Color
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{COLOR_OPTIONS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, color })}
|
||||
className={`w-8 h-8 rounded-full ${
|
||||
formData.color === color ? 'ring-2 ring-offset-2 ring-blue-500' : ''
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active & Default */}
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Active</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_default}
|
||||
onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Default</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseModal}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createAddress.isPending || updateAddress.isPending}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{(createAddress.isPending || updateAddress.isPending) && (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
)}
|
||||
{editingAddress ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
{confirmModal.isOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
{confirmModal.confirmStyle === 'danger' ? (
|
||||
<AlertTriangle className="w-5 h-5 text-red-500" />
|
||||
) : (
|
||||
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
||||
)}
|
||||
{confirmModal.title}
|
||||
</h3>
|
||||
<button
|
||||
onClick={closeConfirmModal}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{confirmModal.message}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={closeConfirmModal}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmModal.onConfirm}
|
||||
disabled={deleteAddress.isPending || removeLocal.isPending}
|
||||
className={`px-4 py-2 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 ${
|
||||
confirmModal.confirmStyle === 'danger'
|
||||
? 'bg-red-600 text-white hover:bg-red-700'
|
||||
: 'bg-orange-600 text-white hover:bg-orange-700'
|
||||
}`}
|
||||
>
|
||||
{(deleteAddress.isPending || removeLocal.isPending) && (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
)}
|
||||
{confirmModal.confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlatformEmailAddressManager;
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield, HelpCircle, Code } from 'lucide-react';
|
||||
import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield, HelpCircle, Code, Mail } from 'lucide-react';
|
||||
import { User } from '../types';
|
||||
import SmoothScheduleLogo from './SmoothScheduleLogo';
|
||||
|
||||
@@ -63,6 +63,10 @@ const PlatformSidebar: React.FC<PlatformSidebarProps> = ({ user, isCollapsed, to
|
||||
<MessageSquare size={18} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.support')}</span>}
|
||||
</Link>
|
||||
<Link to="/platform/email-addresses" className={getNavClass('/platform/email-addresses')} title="Email Addresses">
|
||||
<Mail size={18} className="shrink-0" />
|
||||
{!isCollapsed && <span>Email Addresses</span>}
|
||||
</Link>
|
||||
|
||||
{isSuperuser && (
|
||||
<>
|
||||
@@ -84,6 +88,10 @@ const PlatformSidebar: React.FC<PlatformSidebarProps> = ({ user, isCollapsed, to
|
||||
<HelpCircle size={18} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.help', 'Help')}</span>}
|
||||
</Link>
|
||||
<Link to="/help/email" className={getNavClass('/help/email')} title="Email Settings">
|
||||
<Mail size={18} className="shrink-0" />
|
||||
{!isCollapsed && <span>Email Settings</span>}
|
||||
</Link>
|
||||
<Link to="/help/api" className={getNavClass('/help/api')} title={t('nav.apiDocs', 'API Documentation')}>
|
||||
<Code size={18} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.apiDocs', 'API Docs')}</span>}
|
||||
|
||||
279
frontend/src/components/TicketEmailAddressManager.tsx
Normal file
279
frontend/src/components/TicketEmailAddressManager.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Ticket Email Address Manager Component
|
||||
* Allows businesses to manage their ticket email addresses
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Mail,
|
||||
Plus,
|
||||
Trash2,
|
||||
Edit,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Star,
|
||||
TestTube,
|
||||
RefreshCw,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useTicketEmailAddresses,
|
||||
useDeleteTicketEmailAddress,
|
||||
useTestImapConnection,
|
||||
useTestSmtpConnection,
|
||||
useFetchEmailsNow,
|
||||
useSetAsDefault,
|
||||
TicketEmailAddressListItem,
|
||||
} from '../hooks/useTicketEmailAddresses';
|
||||
import toast from 'react-hot-toast';
|
||||
import TicketEmailAddressModal from './TicketEmailAddressModal';
|
||||
|
||||
const TicketEmailAddressManager: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingAddress, setEditingAddress] = useState<TicketEmailAddressListItem | null>(null);
|
||||
const [showPasswords, setShowPasswords] = useState<Record<number, boolean>>({});
|
||||
|
||||
const { data: emailAddresses = [], isLoading } = useTicketEmailAddresses();
|
||||
const deleteAddress = useDeleteTicketEmailAddress();
|
||||
const testImap = useTestImapConnection();
|
||||
const testSmtp = useTestSmtpConnection();
|
||||
const fetchEmails = useFetchEmailsNow();
|
||||
const setDefault = useSetAsDefault();
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingAddress(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (address: TicketEmailAddressListItem) => {
|
||||
setEditingAddress(address);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number, displayName: string) => {
|
||||
if (confirm(`Are you sure you want to delete ${displayName}?`)) {
|
||||
try {
|
||||
await deleteAddress.mutateAsync(id);
|
||||
toast.success(`${displayName} deleted successfully`);
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete email address');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestImap = async (id: number, displayName: string) => {
|
||||
toast.loading(`Testing IMAP connection for ${displayName}...`, { id: `imap-${id}` });
|
||||
try {
|
||||
const result = await testImap.mutateAsync(id);
|
||||
if (result.success) {
|
||||
toast.success(result.message, { id: `imap-${id}` });
|
||||
} else {
|
||||
toast.error(result.message, { id: `imap-${id}` });
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'IMAP test failed', { id: `imap-${id}` });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestSmtp = async (id: number, displayName: string) => {
|
||||
toast.loading(`Testing SMTP connection for ${displayName}...`, { id: `smtp-${id}` });
|
||||
try {
|
||||
const result = await testSmtp.mutateAsync(id);
|
||||
if (result.success) {
|
||||
toast.success(result.message, { id: `smtp-${id}` });
|
||||
} else {
|
||||
toast.error(result.message, { id: `smtp-${id}` });
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'SMTP test failed', { id: `smtp-${id}` });
|
||||
}
|
||||
};
|
||||
|
||||
const handleFetchEmails = async (id: number, displayName: string) => {
|
||||
toast.loading(`Fetching emails for ${displayName}...`, { id: `fetch-${id}` });
|
||||
try {
|
||||
const result = await fetchEmails.mutateAsync(id);
|
||||
if (result.success) {
|
||||
toast.success(
|
||||
`${result.message}. Processed: ${result.processed || 0}, Errors: ${result.errors || 0}`,
|
||||
{ id: `fetch-${id}`, duration: 5000 }
|
||||
);
|
||||
} else {
|
||||
toast.error(result.message, { id: `fetch-${id}` });
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Failed to fetch emails', { id: `fetch-${id}` });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefault = async (id: number, displayName: string) => {
|
||||
try {
|
||||
const result = await setDefault.mutateAsync(id);
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to set as default');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Mail className="w-6 h-6" />
|
||||
Email Addresses
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Manage email addresses for receiving and sending support tickets
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Email Address
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Email Addresses List */}
|
||||
{emailAddresses.length === 0 ? (
|
||||
<div className="text-center py-12 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<Mail className="w-16 h-16 mx-auto text-gray-400 dark:text-gray-600 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
No email addresses configured
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Add your first email address to start receiving tickets via email
|
||||
</p>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Email Address
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{emailAddresses.map((address) => (
|
||||
<div
|
||||
key={address.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6"
|
||||
style={{ borderLeft: `4px solid ${address.color}` }}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{address.display_name}
|
||||
</h3>
|
||||
{address.is_default && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">
|
||||
<Star className="w-3 h-3" />
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
{address.is_active ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Active
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
|
||||
<XCircle className="w-3 h-3" />
|
||||
Inactive
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-3">{address.email_address}</p>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>
|
||||
Processed: <strong>{address.emails_processed_count}</strong> emails
|
||||
</span>
|
||||
{address.last_check_at && (
|
||||
<span>
|
||||
Last checked: {new Date(address.last_check_at).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!address.is_default && (
|
||||
<button
|
||||
onClick={() => handleSetDefault(address.id, address.display_name)}
|
||||
disabled={setDefault.isPending}
|
||||
className="p-2 text-gray-600 hover:text-yellow-600 dark:text-gray-400 dark:hover:text-yellow-400 transition-colors"
|
||||
title="Set as default"
|
||||
>
|
||||
<Star className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleTestImap(address.id, address.display_name)}
|
||||
disabled={testImap.isPending}
|
||||
className="p-2 text-gray-600 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400 transition-colors"
|
||||
title="Test IMAP"
|
||||
>
|
||||
<TestTube className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleFetchEmails(address.id, address.display_name)}
|
||||
disabled={fetchEmails.isPending}
|
||||
className="p-2 text-gray-600 hover:text-green-600 dark:text-gray-400 dark:hover:text-green-400 transition-colors"
|
||||
title="Fetch emails now"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(address)}
|
||||
className="p-2 text-gray-600 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400 transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(address.id, address.display_name)}
|
||||
disabled={deleteAddress.isPending}
|
||||
className="p-2 text-gray-600 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
{isModalOpen && (
|
||||
<TicketEmailAddressModal
|
||||
address={editingAddress}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false);
|
||||
setEditingAddress(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketEmailAddressManager;
|
||||
508
frontend/src/components/TicketEmailAddressModal.tsx
Normal file
508
frontend/src/components/TicketEmailAddressModal.tsx
Normal file
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* Ticket Email Address Modal Component
|
||||
* Modal for adding/editing ticket email addresses
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Loader2, ChevronDown, ChevronUp, Save, TestTube } from 'lucide-react';
|
||||
import {
|
||||
useCreateTicketEmailAddress,
|
||||
useUpdateTicketEmailAddress,
|
||||
useTicketEmailAddress,
|
||||
useTestImapConnection,
|
||||
useTestSmtpConnection,
|
||||
TicketEmailAddressListItem,
|
||||
TicketEmailAddressCreate,
|
||||
} from '../hooks/useTicketEmailAddresses';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface Props {
|
||||
address?: TicketEmailAddressListItem | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const COLOR_PRESETS = [
|
||||
'#3b82f6', // blue
|
||||
'#10b981', // green
|
||||
'#f59e0b', // amber
|
||||
'#ef4444', // red
|
||||
'#8b5cf6', // purple
|
||||
'#ec4899', // pink
|
||||
'#06b6d4', // cyan
|
||||
'#f97316', // orange
|
||||
'#14b8a6', // teal
|
||||
'#6366f1', // indigo
|
||||
];
|
||||
|
||||
const TicketEmailAddressModal: React.FC<Props> = ({ address, onClose }) => {
|
||||
const [formData, setFormData] = useState<TicketEmailAddressCreate>({
|
||||
display_name: '',
|
||||
email_address: '',
|
||||
color: '#3b82f6',
|
||||
imap_host: '',
|
||||
imap_port: 993,
|
||||
imap_use_ssl: true,
|
||||
imap_username: '',
|
||||
imap_password: '',
|
||||
imap_folder: 'INBOX',
|
||||
smtp_host: '',
|
||||
smtp_port: 587,
|
||||
smtp_use_tls: true,
|
||||
smtp_use_ssl: false,
|
||||
smtp_username: '',
|
||||
smtp_password: '',
|
||||
is_active: true,
|
||||
is_default: false,
|
||||
});
|
||||
|
||||
const [showImapSection, setShowImapSection] = useState(true);
|
||||
const [showSmtpSection, setShowSmtpSection] = useState(true);
|
||||
|
||||
const createAddress = useCreateTicketEmailAddress();
|
||||
const updateAddress = useUpdateTicketEmailAddress();
|
||||
const { data: fullAddress, isLoading: isLoadingAddress } = useTicketEmailAddress(address?.id || 0);
|
||||
const testImap = useTestImapConnection();
|
||||
const testSmtp = useTestSmtpConnection();
|
||||
|
||||
const isEditing = !!address;
|
||||
|
||||
// Load full address details when editing
|
||||
useEffect(() => {
|
||||
if (fullAddress && isEditing) {
|
||||
setFormData({
|
||||
display_name: fullAddress.display_name,
|
||||
email_address: fullAddress.email_address,
|
||||
color: fullAddress.color,
|
||||
imap_host: fullAddress.imap_host,
|
||||
imap_port: fullAddress.imap_port,
|
||||
imap_use_ssl: fullAddress.imap_use_ssl,
|
||||
imap_username: fullAddress.imap_username,
|
||||
imap_password: '', // Don't pre-fill password for security
|
||||
imap_folder: fullAddress.imap_folder,
|
||||
smtp_host: fullAddress.smtp_host,
|
||||
smtp_port: fullAddress.smtp_port,
|
||||
smtp_use_tls: fullAddress.smtp_use_tls,
|
||||
smtp_use_ssl: fullAddress.smtp_use_ssl,
|
||||
smtp_username: fullAddress.smtp_username,
|
||||
smtp_password: '', // Don't pre-fill password for security
|
||||
is_active: fullAddress.is_active,
|
||||
is_default: fullAddress.is_default,
|
||||
});
|
||||
}
|
||||
}, [fullAddress, isEditing]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
if (isEditing && address) {
|
||||
// For updates, only send changed fields
|
||||
const updateData: Partial<TicketEmailAddressCreate> = { ...formData };
|
||||
// Remove passwords if they're empty (not changed)
|
||||
if (!updateData.imap_password) delete updateData.imap_password;
|
||||
if (!updateData.smtp_password) delete updateData.smtp_password;
|
||||
|
||||
await updateAddress.mutateAsync({ id: address.id, data: updateData });
|
||||
toast.success(`${formData.display_name} updated successfully`);
|
||||
} else {
|
||||
await createAddress.mutateAsync(formData);
|
||||
toast.success(`${formData.display_name} added successfully`);
|
||||
}
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to save email address');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestImap = async () => {
|
||||
if (!address) {
|
||||
toast.error('Please save the email address first before testing');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.loading('Testing IMAP connection...', { id: 'test-imap' });
|
||||
try {
|
||||
const result = await testImap.mutateAsync(address.id);
|
||||
if (result.success) {
|
||||
toast.success(result.message, { id: 'test-imap' });
|
||||
} else {
|
||||
toast.error(result.message, { id: 'test-imap' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'IMAP test failed', { id: 'test-imap' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestSmtp = async () => {
|
||||
if (!address) {
|
||||
toast.error('Please save the email address first before testing');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.loading('Testing SMTP connection...', { id: 'test-smtp' });
|
||||
try {
|
||||
const result = await testSmtp.mutateAsync(address.id);
|
||||
if (result.success) {
|
||||
toast.success(result.message, { id: 'test-smtp' });
|
||||
} else {
|
||||
toast.error(result.message, { id: 'test-smtp' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'SMTP test failed', { id: 'test-smtp' });
|
||||
}
|
||||
};
|
||||
|
||||
if (isEditing && isLoadingAddress) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-8">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 overflow-y-auto">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{isEditing ? 'Edit Email Address' : 'Add Email Address'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Basic Information</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.display_name}
|
||||
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })}
|
||||
placeholder="e.g., Support, Billing, Sales"
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email_address}
|
||||
onChange={(e) => setFormData({ ...formData, email_address: e.target.value })}
|
||||
placeholder="support@yourcompany.com"
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Color Tag
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{COLOR_PRESETS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, color })}
|
||||
className={`w-8 h-8 rounded-full border-2 transition-all ${
|
||||
formData.color === color
|
||||
? 'border-gray-900 dark:border-white scale-110'
|
||||
: 'border-transparent'
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
<input
|
||||
type="color"
|
||||
value={formData.color}
|
||||
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
|
||||
className="w-8 h-8 rounded cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Active</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_default}
|
||||
onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Set as Default</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* IMAP Settings */}
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowImapSection(!showImapSection)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
IMAP Settings (Inbound)
|
||||
</h3>
|
||||
{showImapSection ? <ChevronUp className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
|
||||
</button>
|
||||
|
||||
{showImapSection && (
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Host
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.imap_host}
|
||||
onChange={(e) => setFormData({ ...formData, imap_host: e.target.value })}
|
||||
placeholder="imap.gmail.com"
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Port
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.imap_port}
|
||||
onChange={(e) => setFormData({ ...formData, imap_port: parseInt(e.target.value) })}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.imap_username}
|
||||
onChange={(e) => setFormData({ ...formData, imap_username: e.target.value })}
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Password {isEditing && <span className="text-xs text-gray-500">(leave blank to keep current)</span>}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.imap_password}
|
||||
onChange={(e) => setFormData({ ...formData, imap_password: e.target.value })}
|
||||
placeholder={isEditing ? "••••••••" : "password"}
|
||||
required={!isEditing}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Folder
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.imap_folder}
|
||||
onChange={(e) => setFormData({ ...formData, imap_folder: e.target.value })}
|
||||
placeholder="INBOX"
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.imap_use_ssl}
|
||||
onChange={(e) => setFormData({ ...formData, imap_use_ssl: e.target.checked })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Use SSL/TLS</span>
|
||||
</label>
|
||||
|
||||
{isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestImap}
|
||||
disabled={testImap.isPending}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<TestTube className="w-4 h-4" />
|
||||
Test IMAP
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SMTP Settings */}
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSmtpSection(!showSmtpSection)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
SMTP Settings (Outbound)
|
||||
</h3>
|
||||
{showSmtpSection ? <ChevronUp className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
|
||||
</button>
|
||||
|
||||
{showSmtpSection && (
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Host
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.smtp_host}
|
||||
onChange={(e) => setFormData({ ...formData, smtp_host: e.target.value })}
|
||||
placeholder="smtp.gmail.com"
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Port
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.smtp_port}
|
||||
onChange={(e) => setFormData({ ...formData, smtp_port: parseInt(e.target.value) })}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.smtp_username}
|
||||
onChange={(e) => setFormData({ ...formData, smtp_username: e.target.value })}
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Password {isEditing && <span className="text-xs text-gray-500">(leave blank to keep current)</span>}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.smtp_password}
|
||||
onChange={(e) => setFormData({ ...formData, smtp_password: e.target.value })}
|
||||
placeholder={isEditing ? "••••••••" : "password"}
|
||||
required={!isEditing}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.smtp_use_tls}
|
||||
onChange={(e) => setFormData({ ...formData, smtp_use_tls: e.target.checked })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Use STARTTLS (Port 587)</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.smtp_use_ssl}
|
||||
onChange={(e) => setFormData({ ...formData, smtp_use_ssl: e.target.checked })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Use SSL/TLS (Port 465)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestSmtp}
|
||||
disabled={testSmtp.isPending}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<TestTube className="w-4 h-4" />
|
||||
Test SMTP
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createAddress.isPending || updateAddress.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{(createAddress.isPending || updateAddress.isPending) && (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
)}
|
||||
<Save className="w-4 h-4" />
|
||||
{isEditing ? 'Update' : 'Add'} Email Address
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketEmailAddressModal;
|
||||
Reference in New Issue
Block a user