diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..3bbbaa9 --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,5 @@ +# robots.txt - SmoothSchedule +# Deny all robots while in development + +User-agent: * +Disallow: / diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e4c9a2a..e283810 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -29,6 +29,8 @@ const PricingPage = React.lazy(() => import('./pages/marketing/PricingPage')); const AboutPage = React.lazy(() => import('./pages/marketing/AboutPage')); const ContactPage = React.lazy(() => import('./pages/marketing/ContactPage')); const SignupPage = React.lazy(() => import('./pages/marketing/SignupPage')); +const PrivacyPolicyPage = React.lazy(() => import('./pages/marketing/PrivacyPolicyPage')); +const TermsOfServicePage = React.lazy(() => import('./pages/marketing/TermsOfServicePage')); // Import pages const Dashboard = React.lazy(() => import('./pages/Dashboard')); @@ -50,6 +52,7 @@ const Upgrade = React.lazy(() => import('./pages/Upgrade')); const PlatformDashboard = React.lazy(() => import('./pages/platform/PlatformDashboard')); const PlatformBusinesses = React.lazy(() => import('./pages/platform/PlatformBusinesses')); const PlatformSupportPage = React.lazy(() => import('./pages/platform/PlatformSupport')); +const PlatformEmailAddresses = React.lazy(() => import('./pages/platform/PlatformEmailAddresses')); const PlatformUsers = React.lazy(() => import('./pages/platform/PlatformUsers')); const PlatformStaff = React.lazy(() => import('./pages/platform/PlatformStaff')); const PlatformSettings = React.lazy(() => import('./pages/platform/PlatformSettings')); @@ -63,6 +66,7 @@ const HelpGuide = React.lazy(() => import('./pages/HelpGuide')); // Import Platf const HelpTicketing = React.lazy(() => import('./pages/HelpTicketing')); // Import Help page for ticketing const HelpApiDocs = React.lazy(() => import('./pages/HelpApiDocs')); // Import API documentation page const HelpPluginDocs = React.lazy(() => import('./pages/HelpPluginDocs')); // Import Plugin documentation page +const HelpEmailSettings = React.lazy(() => import('./pages/HelpEmailSettings')); // Import Email settings help page const PlatformSupport = React.lazy(() => import('./pages/PlatformSupport')); // Import Platform Support page (for businesses to contact SmoothSchedule) const PluginMarketplace = React.lazy(() => import('./pages/PluginMarketplace')); // Import Plugin Marketplace page const MyPlugins = React.lazy(() => import('./pages/MyPlugins')); // Import My Plugins page @@ -228,6 +232,8 @@ const AppContent: React.FC = () => { } /> } /> } /> + } /> + } /> } /> } /> @@ -269,6 +275,8 @@ const AppContent: React.FC = () => { } /> } /> } /> + } /> + } /> } /> } /> @@ -376,10 +384,12 @@ const AppContent: React.FC = () => { )} } /> + } /> } /> } /> } /> } /> + } /> {user.role === 'superuser' && ( } /> )} @@ -567,6 +577,7 @@ const AppContent: React.FC = () => { } /> } /> } /> + } /> => { + const response = await apiClient.get('/platform/email-addresses/'); + return response.data; +}; + +/** + * Get a specific platform email address by ID + */ +export const getPlatformEmailAddress = async (id: number): Promise => { + const response = await apiClient.get(`/platform/email-addresses/${id}/`); + return response.data; +}; + +/** + * Create a new platform email address + */ +export const createPlatformEmailAddress = async ( + data: PlatformEmailAddressCreate +): Promise => { + const response = await apiClient.post('/platform/email-addresses/', data); + return response.data; +}; + +/** + * Update an existing platform email address + */ +export const updatePlatformEmailAddress = async ( + id: number, + data: PlatformEmailAddressUpdate +): Promise => { + const response = await apiClient.patch(`/platform/email-addresses/${id}/`, data); + return response.data; +}; + +/** + * Delete a platform email address (also removes from mail server) + */ +export const deletePlatformEmailAddress = async (id: number): Promise => { + await apiClient.delete(`/platform/email-addresses/${id}/`); +}; + +/** + * Remove email address from database only (keeps mail server account) + */ +export const removeLocalPlatformEmailAddress = async (id: number): Promise<{ success: boolean; message: string }> => { + const response = await apiClient.post(`/platform/email-addresses/${id}/remove_local/`); + return response.data; +}; + +/** + * Sync email address to mail server + */ +export const syncPlatformEmailAddress = async (id: number): Promise => { + const response = await apiClient.post(`/platform/email-addresses/${id}/sync/`); + return response.data; +}; + +/** + * Test IMAP connection for a platform email address + */ +export const testImapConnection = async (id: number): Promise => { + const response = await apiClient.post(`/platform/email-addresses/${id}/test_imap/`); + return response.data; +}; + +/** + * Test SMTP connection for a platform email address + */ +export const testSmtpConnection = async (id: number): Promise => { + const response = await apiClient.post(`/platform/email-addresses/${id}/test_smtp/`); + return response.data; +}; + +/** + * Set a platform email address as the default + */ +export const setAsDefault = async (id: number): Promise<{ success: boolean; message: string }> => { + const response = await apiClient.post(`/platform/email-addresses/${id}/set_as_default/`); + return response.data; +}; + +/** + * Test SSH connection to the mail server + */ +export const testMailServerConnection = async (): Promise => { + const response = await apiClient.post('/platform/email-addresses/test_mail_server/'); + return response.data; +}; + +/** + * Get all email accounts from the mail server + */ +export const getMailServerAccounts = async (): Promise => { + const response = await apiClient.get('/platform/email-addresses/mail_server_accounts/'); + return response.data; +}; + +/** + * Get available email domains + */ +export const getAvailableDomains = async (): Promise<{ domains: EmailDomain[] }> => { + const response = await apiClient.get('/platform/email-addresses/available_domains/'); + return response.data; +}; + +/** + * Get assignable users (platform users who can be assigned to email addresses) + */ +export const getAssignableUsers = async (): Promise<{ users: AssignableUser[] }> => { + const response = await apiClient.get('/platform/email-addresses/assignable_users/'); + return response.data; +}; + +/** + * Import email addresses from the mail server + */ +export const importFromMailServer = async (): Promise => { + const response = await apiClient.post('/platform/email-addresses/import_from_mail_server/'); + return response.data; +}; diff --git a/frontend/src/api/ticketEmailAddresses.ts b/frontend/src/api/ticketEmailAddresses.ts new file mode 100644 index 0000000..16ecbc7 --- /dev/null +++ b/frontend/src/api/ticketEmailAddresses.ts @@ -0,0 +1,157 @@ +/** + * API client for Ticket Email Addresses + */ + +import apiClient from './client'; + +export interface TicketEmailAddress { + id: number; + tenant: number; + tenant_name: string; + display_name: string; + email_address: string; + color: string; + imap_host: string; + imap_port: number; + imap_use_ssl: boolean; + imap_username: string; + imap_password?: string; + imap_folder: string; + smtp_host: string; + smtp_port: number; + smtp_use_tls: boolean; + smtp_use_ssl: boolean; + smtp_username: string; + smtp_password?: string; + is_active: boolean; + is_default: boolean; + last_check_at?: string; + last_error?: string; + emails_processed_count: number; + created_at: string; + updated_at: string; + is_imap_configured: boolean; + is_smtp_configured: boolean; + is_fully_configured: boolean; +} + +export interface TicketEmailAddressListItem { + id: number; + display_name: string; + email_address: string; + color: string; + is_active: boolean; + is_default: boolean; + last_check_at?: string; + emails_processed_count: number; + created_at: string; + updated_at: string; +} + +export interface TicketEmailAddressCreate { + display_name: string; + email_address: string; + color: string; + imap_host: string; + imap_port: number; + imap_use_ssl: boolean; + imap_username: string; + imap_password: string; + imap_folder: string; + smtp_host: string; + smtp_port: number; + smtp_use_tls: boolean; + smtp_use_ssl: boolean; + smtp_username: string; + smtp_password: string; + is_active: boolean; + is_default: boolean; +} + +export interface TestConnectionResponse { + success: boolean; + message: string; +} + +export interface FetchEmailsResponse { + success: boolean; + message: string; + processed?: number; + errors?: number; +} + +/** + * Get all ticket email addresses for the current business + */ +export const getTicketEmailAddresses = async (): Promise => { + const response = await apiClient.get('/tickets/email-addresses/'); + return response.data; +}; + +/** + * Get a specific ticket email address by ID + */ +export const getTicketEmailAddress = async (id: number): Promise => { + const response = await apiClient.get(`/tickets/email-addresses/${id}/`); + return response.data; +}; + +/** + * Create a new ticket email address + */ +export const createTicketEmailAddress = async ( + data: TicketEmailAddressCreate +): Promise => { + const response = await apiClient.post('/tickets/email-addresses/', data); + return response.data; +}; + +/** + * Update an existing ticket email address + */ +export const updateTicketEmailAddress = async ( + id: number, + data: Partial +): Promise => { + const response = await apiClient.patch(`/tickets/email-addresses/${id}/`, data); + return response.data; +}; + +/** + * Delete a ticket email address + */ +export const deleteTicketEmailAddress = async (id: number): Promise => { + await apiClient.delete(`/tickets/email-addresses/${id}/`); +}; + +/** + * Test IMAP connection for an email address + */ +export const testImapConnection = async (id: number): Promise => { + const response = await apiClient.post(`/tickets/email-addresses/${id}/test_imap/`); + return response.data; +}; + +/** + * Test SMTP connection for an email address + */ +export const testSmtpConnection = async (id: number): Promise => { + const response = await apiClient.post(`/tickets/email-addresses/${id}/test_smtp/`); + return response.data; +}; + +/** + * Manually fetch emails for an email address + */ +export const fetchEmailsNow = async (id: number): Promise => { + const response = await apiClient.post(`/tickets/email-addresses/${id}/fetch_now/`); + return response.data; +}; + +/** + * Set an email address as the default for the business + */ +export const setAsDefault = async (id: number): Promise<{ success: boolean; message: string }> => { + const response = await apiClient.post(`/tickets/email-addresses/${id}/set_as_default/`); + return response.data; +}; diff --git a/frontend/src/api/tickets.ts b/frontend/src/api/tickets.ts index 2829940..ad1a0c1 100644 --- a/frontend/src/api/tickets.ts +++ b/frontend/src/api/tickets.ts @@ -66,3 +66,23 @@ export const getCannedResponses = async (): Promise => { const response = await apiClient.get('/tickets/canned-responses/'); return response.data; }; + +// Refresh emails manually +export interface RefreshEmailsResult { + success: boolean; + processed: number; + results: { + address: string | null; + display_name?: string; + processed?: number; + status: string; + error?: string; + message?: string; + last_check_at?: string; + }[]; +} + +export const refreshTicketEmails = async (): Promise => { + const response = await apiClient.post('/tickets/refresh-emails/'); + return response.data; +}; diff --git a/frontend/src/components/PlatformEmailAddressManager.tsx b/frontend/src/components/PlatformEmailAddressManager.tsx new file mode 100644 index 0000000..3d10a28 --- /dev/null +++ b/frontend/src/components/PlatformEmailAddressManager.tsx @@ -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(null); + const [confirmModal, setConfirmModal] = useState({ + isOpen: false, + title: '', + message: '', + confirmText: 'Confirm', + confirmStyle: 'danger', + onConfirm: () => {}, + }); + const [formData, setFormData] = useState({ + 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>({}); + + 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 = {}; + + 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 ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

+ Email addresses are managed directly on the mail server (mail.talova.net) +

+
+
+ + + +
+
+ + {/* Email Addresses List */} + {emailAddresses.length === 0 ? ( +
+ +

+ No email addresses configured +

+

+ Add your first platform email address to start receiving support tickets +

+ +
+ ) : ( +
+ {emailAddresses.map((address) => ( +
+
+
+
+

+ {address.display_name} +

+ {address.is_default && ( + + + Default + + )} + {address.is_active ? ( + + + Active + + ) : ( + + + Inactive + + )} + {address.mail_server_synced ? ( + + + Synced + + ) : ( + + + Not Synced + + )} +
+

{address.email_address}

+
+ + Processed: {address.emails_processed_count} emails + + {address.last_check_at && ( + + Last checked: {new Date(address.last_check_at).toLocaleString()} + + )} +
+
+
+ {!address.is_default && ( + + )} + + + + + +
+
+
+ ))} +
+ )} + + {/* Modal */} + {isModalOpen && ( +
+
+
+

+ {editingAddress ? 'Edit Email Address' : 'Add Email Address'} +

+ +
+ +
+ {/* Display Name */} +
+ + 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 && ( +

{formErrors.display_name}

+ )} +
+ + {/* Sender Name */} +
+ + 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" + /> +

+ Name shown in the From field of outgoing emails. If blank, uses Display Name. +

+
+ + {/* Assigned User */} +
+ + +

+ If assigned, the user's name will be used as the sender name in outgoing emails. +

+
+ + {/* Email Address (only show for new addresses) */} + {!editingAddress && ( +
+ +
+ 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" + /> + + @smoothschedule.com + +
+ {formErrors.local_part && ( +

{formErrors.local_part}

+ )} +
+ )} + + {/* Password */} +
+ + 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 && ( +

{formErrors.password}

+ )} +

+ Minimum 8 characters. This password will be synced to the mail server. +

+
+ + {/* Color */} +
+ +
+ {COLOR_OPTIONS.map((color) => ( +
+
+ + {/* Active & Default */} +
+ + +
+ + {/* Actions */} +
+ + +
+
+
+
+ )} + + {/* Confirmation Modal */} + {confirmModal.isOpen && ( +
+
+
+

+ {confirmModal.confirmStyle === 'danger' ? ( + + ) : ( + + )} + {confirmModal.title} +

+ +
+
+

+ {confirmModal.message} +

+
+
+ + +
+
+
+ )} +
+ ); +}; + +export default PlatformEmailAddressManager; diff --git a/frontend/src/components/PlatformSidebar.tsx b/frontend/src/components/PlatformSidebar.tsx index 9e51a12..b9a60d5 100644 --- a/frontend/src/components/PlatformSidebar.tsx +++ b/frontend/src/components/PlatformSidebar.tsx @@ -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 = ({ user, isCollapsed, to {!isCollapsed && {t('nav.support')}} + + + {!isCollapsed && Email Addresses} + {isSuperuser && ( <> @@ -84,6 +88,10 @@ const PlatformSidebar: React.FC = ({ user, isCollapsed, to {!isCollapsed && {t('nav.help', 'Help')}} + + + {!isCollapsed && Email Settings} + {!isCollapsed && {t('nav.apiDocs', 'API Docs')}} diff --git a/frontend/src/components/TicketEmailAddressManager.tsx b/frontend/src/components/TicketEmailAddressManager.tsx new file mode 100644 index 0000000..8a0c950 --- /dev/null +++ b/frontend/src/components/TicketEmailAddressManager.tsx @@ -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(null); + const [showPasswords, setShowPasswords] = useState>({}); + + 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 ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

+ + Email Addresses +

+

+ Manage email addresses for receiving and sending support tickets +

+
+ +
+ + {/* Email Addresses List */} + {emailAddresses.length === 0 ? ( +
+ +

+ No email addresses configured +

+

+ Add your first email address to start receiving tickets via email +

+ +
+ ) : ( +
+ {emailAddresses.map((address) => ( +
+
+
+
+

+ {address.display_name} +

+ {address.is_default && ( + + + Default + + )} + {address.is_active ? ( + + + Active + + ) : ( + + + Inactive + + )} +
+

{address.email_address}

+
+ + Processed: {address.emails_processed_count} emails + + {address.last_check_at && ( + + Last checked: {new Date(address.last_check_at).toLocaleString()} + + )} +
+
+
+ {!address.is_default && ( + + )} + + + + +
+
+
+ ))} +
+ )} + + {/* Modal */} + {isModalOpen && ( + { + setIsModalOpen(false); + setEditingAddress(null); + }} + /> + )} +
+ ); +}; + +export default TicketEmailAddressManager; diff --git a/frontend/src/components/TicketEmailAddressModal.tsx b/frontend/src/components/TicketEmailAddressModal.tsx new file mode 100644 index 0000000..a445411 --- /dev/null +++ b/frontend/src/components/TicketEmailAddressModal.tsx @@ -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 = ({ address, onClose }) => { + const [formData, setFormData] = useState({ + 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 = { ...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 ( +
+
+ +
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+

+ {isEditing ? 'Edit Email Address' : 'Add Email Address'} +

+ +
+ + {/* Form */} +
+ {/* Basic Info */} +
+

Basic Information

+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ +
+ {COLOR_PRESETS.map((color) => ( +
+
+ +
+ + +
+
+ + {/* IMAP Settings */} +
+ + + {showImapSection && ( +
+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ + + + {isEditing && ( + + )} +
+ )} +
+ + {/* SMTP Settings */} +
+ + + {showSmtpSection && ( +
+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + +
+ + {isEditing && ( + + )} +
+ )} +
+ + {/* Actions */} +
+ + +
+
+
+
+ ); +}; + +export default TicketEmailAddressModal; diff --git a/frontend/src/hooks/usePlatformEmailAddresses.ts b/frontend/src/hooks/usePlatformEmailAddresses.ts new file mode 100644 index 0000000..8404ae3 --- /dev/null +++ b/frontend/src/hooks/usePlatformEmailAddresses.ts @@ -0,0 +1,207 @@ +/** + * React Query hooks for Platform Email Addresses + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + getPlatformEmailAddresses, + getPlatformEmailAddress, + createPlatformEmailAddress, + updatePlatformEmailAddress, + deletePlatformEmailAddress, + removeLocalPlatformEmailAddress, + syncPlatformEmailAddress, + testImapConnection, + testSmtpConnection, + setAsDefault, + testMailServerConnection, + getMailServerAccounts, + getAvailableDomains, + getAssignableUsers, + importFromMailServer, + PlatformEmailAddressListItem, + PlatformEmailAddress, + PlatformEmailAddressCreate, + PlatformEmailAddressUpdate, +} from '../api/platformEmailAddresses'; + +export type { PlatformEmailAddressListItem, PlatformEmailAddress }; + +const QUERY_KEY = 'platformEmailAddresses'; + +/** + * Hook to fetch all platform email addresses + */ +export const usePlatformEmailAddresses = () => { + return useQuery({ + queryKey: [QUERY_KEY], + queryFn: getPlatformEmailAddresses, + }); +}; + +/** + * Hook to fetch a single platform email address + */ +export const usePlatformEmailAddress = (id: number) => { + return useQuery({ + queryKey: [QUERY_KEY, id], + queryFn: () => getPlatformEmailAddress(id), + enabled: !!id, + }); +}; + +/** + * Hook to create a new platform email address + */ +export const useCreatePlatformEmailAddress = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: PlatformEmailAddressCreate) => createPlatformEmailAddress(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); + }, + }); +}; + +/** + * Hook to update a platform email address + */ +export const useUpdatePlatformEmailAddress = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, data }: { id: number; data: PlatformEmailAddressUpdate }) => + updatePlatformEmailAddress(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); + }, + }); +}; + +/** + * Hook to delete a platform email address (also removes from mail server) + */ +export const useDeletePlatformEmailAddress = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: number) => deletePlatformEmailAddress(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); + }, + }); +}; + +/** + * Hook to remove email address from database only (keeps mail server account) + */ +export const useRemoveLocalPlatformEmailAddress = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: number) => removeLocalPlatformEmailAddress(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); + }, + }); +}; + +/** + * Hook to sync a platform email address to the mail server + */ +export const useSyncPlatformEmailAddress = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: number) => syncPlatformEmailAddress(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); + }, + }); +}; + +/** + * Hook to test IMAP connection + */ +export const useTestImapConnection = () => { + return useMutation({ + mutationFn: (id: number) => testImapConnection(id), + }); +}; + +/** + * Hook to test SMTP connection + */ +export const useTestSmtpConnection = () => { + return useMutation({ + mutationFn: (id: number) => testSmtpConnection(id), + }); +}; + +/** + * Hook to set email address as default + */ +export const useSetAsDefault = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: number) => setAsDefault(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); + }, + }); +}; + +/** + * Hook to test mail server SSH connection + */ +export const useTestMailServerConnection = () => { + return useMutation({ + mutationFn: testMailServerConnection, + }); +}; + +/** + * Hook to get mail server accounts + */ +export const useMailServerAccounts = () => { + return useQuery({ + queryKey: [QUERY_KEY, 'mailServerAccounts'], + queryFn: getMailServerAccounts, + }); +}; + +/** + * Hook to get available email domains + */ +export const useAvailableDomains = () => { + return useQuery({ + queryKey: [QUERY_KEY, 'availableDomains'], + queryFn: getAvailableDomains, + }); +}; + +/** + * Hook to get assignable users + */ +export const useAssignableUsers = () => { + return useQuery({ + queryKey: [QUERY_KEY, 'assignableUsers'], + queryFn: getAssignableUsers, + }); +}; + +/** + * Hook to import email addresses from the mail server + */ +export const useImportFromMailServer = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: importFromMailServer, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); + }, + }); +}; diff --git a/frontend/src/hooks/usePlatformSettings.ts b/frontend/src/hooks/usePlatformSettings.ts index 8e91b04..b5ca5ca 100644 --- a/frontend/src/hooks/usePlatformSettings.ts +++ b/frontend/src/hooks/usePlatformSettings.ts @@ -15,9 +15,14 @@ export interface PlatformSettings { stripe_validation_error: string; has_stripe_keys: boolean; stripe_keys_from_env: boolean; + email_check_interval_minutes: number; updated_at: string; } +export interface GeneralSettingsUpdate { + email_check_interval_minutes?: number; +} + export interface StripeKeysUpdate { stripe_secret_key?: string; stripe_publishable_key?: string; @@ -35,10 +40,14 @@ export interface SubscriptionPlan { price_yearly: string | null; business_tier: string; features: string[]; + limits: Record; + permissions: Record; transaction_fee_percent: string; transaction_fee_fixed: string; is_active: boolean; is_public: boolean; + is_most_popular: boolean; + show_price: boolean; created_at: string; updated_at: string; } @@ -51,10 +60,14 @@ export interface SubscriptionPlanCreate { price_yearly?: number | null; business_tier?: string; features?: string[]; + limits?: Record; + permissions?: Record; transaction_fee_percent?: number; transaction_fee_fixed?: number; is_active?: boolean; is_public?: boolean; + is_most_popular?: boolean; + show_price?: boolean; create_stripe_product?: boolean; stripe_product_id?: string; stripe_price_id?: string; @@ -74,6 +87,23 @@ export const usePlatformSettings = () => { }); }; +/** + * Hook to update general platform settings + */ +export const useUpdateGeneralSettings = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (settings: GeneralSettingsUpdate) => { + const { data } = await apiClient.post('/platform/settings/general/', settings); + return data; + }, + onSuccess: (data) => { + queryClient.setQueryData(['platformSettings'], data); + }, + }); +}; + /** * Hook to update platform Stripe keys */ @@ -148,7 +178,7 @@ export const useUpdateSubscriptionPlan = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ id, ...updates }: Partial & { id: number }) => { + mutationFn: async ({ id, ...updates }: Partial & { id: number }) => { const { data } = await apiClient.patch(`/platform/subscription-plans/${id}/`, updates); return data; }, diff --git a/frontend/src/hooks/useTicketEmailAddresses.ts b/frontend/src/hooks/useTicketEmailAddresses.ts new file mode 100644 index 0000000..1c325f1 --- /dev/null +++ b/frontend/src/hooks/useTicketEmailAddresses.ts @@ -0,0 +1,141 @@ +/** + * React Query hooks for ticket email addresses + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + getTicketEmailAddresses, + getTicketEmailAddress, + createTicketEmailAddress, + updateTicketEmailAddress, + deleteTicketEmailAddress, + testImapConnection, + testSmtpConnection, + fetchEmailsNow, + setAsDefault, + TicketEmailAddress, + TicketEmailAddressListItem, + TicketEmailAddressCreate, +} from '../api/ticketEmailAddresses'; + +const QUERY_KEY = 'ticketEmailAddresses'; + +/** + * Hook to fetch all ticket email addresses + */ +export const useTicketEmailAddresses = () => { + return useQuery({ + queryKey: [QUERY_KEY], + queryFn: getTicketEmailAddresses, + }); +}; + +/** + * Hook to fetch a specific ticket email address + */ +export const useTicketEmailAddress = (id: number) => { + return useQuery({ + queryKey: [QUERY_KEY, id], + queryFn: () => getTicketEmailAddress(id), + enabled: !!id, + }); +}; + +/** + * Hook to create a new ticket email address + */ +export const useCreateTicketEmailAddress = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: TicketEmailAddressCreate) => createTicketEmailAddress(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); + }, + }); +}; + +/** + * Hook to update an existing ticket email address + */ +export const useUpdateTicketEmailAddress = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, data }: { id: number; data: Partial }) => + updateTicketEmailAddress(id, data), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEY, variables.id] }); + }, + }); +}; + +/** + * Hook to delete a ticket email address + */ +export const useDeleteTicketEmailAddress = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: number) => deleteTicketEmailAddress(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); + }, + }); +}; + +/** + * Hook to test IMAP connection + */ +export const useTestImapConnection = () => { + return useMutation({ + mutationFn: (id: number) => testImapConnection(id), + }); +}; + +/** + * Hook to test SMTP connection + */ +export const useTestSmtpConnection = () => { + return useMutation({ + mutationFn: (id: number) => testSmtpConnection(id), + }); +}; + +/** + * Hook to manually fetch emails + */ +export const useFetchEmailsNow = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: number) => fetchEmailsNow(id), + onSuccess: () => { + // Refresh the email addresses list to update the last_check_at timestamp + queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); + // Also invalidate tickets query to show any new tickets + queryClient.invalidateQueries({ queryKey: ['tickets'] }); + }, + }); +}; + +/** + * Hook to set an email address as default + */ +export const useSetAsDefault = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: number) => setAsDefault(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); + }, + }); +}; + +export type { + TicketEmailAddress, + TicketEmailAddressListItem, + TicketEmailAddressCreate, +}; diff --git a/frontend/src/hooks/useTickets.ts b/frontend/src/hooks/useTickets.ts index 46f9e51..4c8bd79 100644 --- a/frontend/src/hooks/useTickets.ts +++ b/frontend/src/hooks/useTickets.ts @@ -274,3 +274,19 @@ export const useCannedResponses = () => { }, }); }; + +/** + * Hook to manually refresh/check for new ticket emails + */ +export const useRefreshTicketEmails = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ticketsApi.refreshTicketEmails, + onSuccess: (data) => { + // Refresh tickets list if any emails were processed + if (data.processed > 0) { + queryClient.invalidateQueries({ queryKey: ['tickets'] }); + } + }, + }); +}; diff --git a/frontend/src/pages/HelpEmailSettings.tsx b/frontend/src/pages/HelpEmailSettings.tsx new file mode 100644 index 0000000..42cb13b --- /dev/null +++ b/frontend/src/pages/HelpEmailSettings.tsx @@ -0,0 +1,294 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Mail, Server, Lock, Copy, Check, Shield, Smartphone, Monitor, Globe } from 'lucide-react'; +import { useState } from 'react'; + +interface CopyButtonProps { + text: string; +} + +const CopyButton: React.FC = ({ text }) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + return ( + + ); +}; + +interface SettingRowProps { + label: string; + value: string; + monospace?: boolean; +} + +const SettingRow: React.FC = ({ label, value, monospace = true }) => ( +
+ {label} +
+ + {value} + + +
+
+); + +const HelpEmailSettings: React.FC = () => { + const { t } = useTranslation(); + + return ( +
+ {/* Header */} +
+

+ + {t('help.email.title', 'Email Client Settings')} +

+

+ {t('help.email.subtitle', 'Configure your email client to send and receive emails using your SmoothSchedule platform email address')} +

+
+ + {/* Quick Start */} +
+

+ + Quick Reference +

+

+ Use these settings to configure any email client. Your username is your full email address, and your password is the one you set when creating the email address. +

+
+
+

+ + Incoming Mail (IMAP) +

+
+ + + +
+
+
+

+ + Outgoing Mail (SMTP) +

+
+ + + +
+
+
+
+ + {/* Security Note */} +
+
+ +
+

Security Notice

+

+ Always ensure your email client is configured to use encrypted connections (SSL/TLS or STARTTLS). + Never connect using unencrypted ports (25, 110, 143 without encryption). +

+
+
+
+ + {/* Desktop Clients */} +
+
+

+ + Desktop Email Clients +

+
+ + {/* Outlook */} +
+

Microsoft Outlook

+
    +
  1. Go to File > Add Account
  2. +
  3. Enter your email address and click Advanced options
  4. +
  5. Check Let me set up my account manually
  6. +
  7. Select IMAP
  8. +
  9. Enter the incoming and outgoing server settings from above
  10. +
  11. Enter your password when prompted
  12. +
  13. Click Connect to complete setup
  14. +
+
+ + {/* Apple Mail */} +
+

Apple Mail (macOS)

+
    +
  1. Open Mail and go to Mail > Add Account
  2. +
  3. Select Other Mail Account and click Continue
  4. +
  5. Enter your name, email address, and password
  6. +
  7. If automatic setup fails, enter the server settings manually: +
      +
    • Account Type: IMAP
    • +
    • Incoming Mail Server: mail.talova.net
    • +
    • Outgoing Mail Server: mail.talova.net
    • +
    +
  8. +
  9. Click Sign In to complete
  10. +
+
+ + {/* Thunderbird */} +
+

Mozilla Thunderbird

+
    +
  1. Go to Account Settings > Account Actions > Add Mail Account
  2. +
  3. Enter your name, email address, and password
  4. +
  5. Click Configure manually
  6. +
  7. Configure incoming server: +
      +
    • Protocol: IMAP
    • +
    • Hostname: mail.talova.net
    • +
    • Port: 993
    • +
    • Connection Security: SSL/TLS
    • +
    • Authentication: Normal password
    • +
    +
  8. +
  9. Configure outgoing server: +
      +
    • Hostname: mail.talova.net
    • +
    • Port: 587
    • +
    • Connection Security: STARTTLS
    • +
    • Authentication: Normal password
    • +
    +
  10. +
  11. Click Done
  12. +
+
+
+ + {/* Mobile Clients */} +
+
+

+ + Mobile Email Apps +

+
+ + {/* iOS */} +
+

iPhone / iPad (iOS Mail)

+
    +
  1. Go to Settings > Mail > Accounts > Add Account
  2. +
  3. Select Other > Add Mail Account
  4. +
  5. Enter your name, email, password, and a description
  6. +
  7. Tap Next and select IMAP
  8. +
  9. For Incoming Mail Server: +
      +
    • Host Name: mail.talova.net
    • +
    • User Name: your full email address
    • +
    • Password: your email password
    • +
    +
  10. +
  11. For Outgoing Mail Server: +
      +
    • Host Name: mail.talova.net
    • +
    • User Name: your full email address
    • +
    • Password: your email password
    • +
    +
  12. +
  13. Tap Save
  14. +
+
+ + {/* Android */} +
+

Android (Gmail App)

+
    +
  1. Open the Gmail app and tap your profile icon
  2. +
  3. Tap Add another account > Other
  4. +
  5. Enter your email address and tap Next
  6. +
  7. Select Personal (IMAP)
  8. +
  9. Enter your password
  10. +
  11. For incoming server settings: +
      +
    • Server: mail.talova.net
    • +
    • Port: 993
    • +
    • Security type: SSL/TLS
    • +
    +
  12. +
  13. For outgoing server settings: +
      +
    • Server: mail.talova.net
    • +
    • Port: 587
    • +
    • Security type: STARTTLS
    • +
    +
  14. +
  15. Complete the setup
  16. +
+
+
+ + {/* Troubleshooting */} +
+
+

+ + Troubleshooting +

+
+
+
+

Cannot connect to server

+

+ Make sure you're using the correct port numbers (993 for IMAP, 587 for SMTP) and that your firewall isn't blocking these ports. +

+
+
+

Authentication failed

+

+ Verify that your username is your full email address (e.g., support@talova.net) and that you're using the correct password. + If you've forgotten your password, you can reset it from the Email Addresses page in Platform Settings. +

+
+
+

Certificate warnings

+

+ If you see SSL certificate warnings, ensure your device's date and time are correct. The mail server uses a valid SSL certificate that should be trusted by all modern devices. +

+
+
+

Emails not syncing

+

+ Check your sync frequency settings in your email client. Some clients may be set to manual sync by default. Also verify that the email address is active in Platform Settings. +

+
+
+
+
+ ); +}; + +export default HelpEmailSettings; diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index f4ffeb3..4a93cbc 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useOutletContext } from 'react-router-dom'; import { Business, User, CustomDomain } from '../types'; -import { Save, Globe, Palette, BookKey, Check, Sparkles, CheckCircle, Link2, AlertCircle, ExternalLink, Copy, Crown, ShieldCheck, Trash2, RefreshCw, Star, Eye, EyeOff, Key, ShoppingCart, Building2, Users, Lock, Wallet, X, Plus, Layers, Pencil, Upload, Image as ImageIcon } from 'lucide-react'; +import { Save, Globe, Palette, BookKey, Check, Sparkles, CheckCircle, Link2, AlertCircle, ExternalLink, Copy, Crown, ShieldCheck, Trash2, RefreshCw, Star, Eye, EyeOff, Key, ShoppingCart, Building2, Users, Lock, Wallet, X, Plus, Layers, Pencil, Upload, Image as ImageIcon, Mail } from 'lucide-react'; import DomainPurchase from '../components/DomainPurchase'; import { useBusinessOAuthSettings, useUpdateBusinessOAuthSettings } from '../hooks/useBusinessOAuth'; import { useCustomDomains, useAddCustomDomain, useDeleteCustomDomain, useVerifyCustomDomain, useSetPrimaryDomain } from '../hooks/useCustomDomains'; @@ -10,6 +10,7 @@ import { useBusinessOAuthCredentials, useUpdateBusinessOAuthCredentials } from ' import { useResourceTypes, useCreateResourceType, useUpdateResourceType, useDeleteResourceType } from '../hooks/useResourceTypes'; import OnboardingWizard from '../components/OnboardingWizard'; import ApiTokensSection from '../components/ApiTokensSection'; +import TicketEmailAddressManager from '../components/TicketEmailAddressManager'; // Curated color palettes with complementary primary and secondary colors const colorPalettes = [ @@ -99,7 +100,7 @@ const colorPalettes = [ }, ]; -type SettingsTab = 'general' | 'domains' | 'authentication' | 'resources' | 'api-tokens'; +type SettingsTab = 'general' | 'domains' | 'authentication' | 'resources' | 'api-tokens' | 'email-addresses'; // Resource Types Management Section Component const ResourceTypesSection: React.FC = () => { @@ -647,6 +648,7 @@ const SettingsPage: React.FC = () => { { id: 'domains' as const, label: 'Domains', icon: Globe }, { id: 'authentication' as const, label: 'Authentication', icon: Lock }, { id: 'api-tokens' as const, label: 'API Tokens', icon: Key }, + { id: 'email-addresses' as const, label: 'Email Addresses', icon: Mail }, ]; return ( @@ -1860,6 +1862,11 @@ const SettingsPage: React.FC = () => { )} + {/* EMAIL ADDRESSES TAB */} + {activeTab === 'email-addresses' && isOwner && ( + + )} + {/* Floating Action Buttons */}
diff --git a/frontend/src/pages/Tickets.tsx b/frontend/src/pages/Tickets.tsx index f13b885..cfd24db 100644 --- a/frontend/src/pages/Tickets.tsx +++ b/frontend/src/pages/Tickets.tsx @@ -224,6 +224,11 @@ const Tickets: React.FC = () => { key={ticket.id} onClick={() => openTicketModal(ticket)} className="bg-white dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-4 hover:border-brand-300 dark:hover:border-brand-600 hover:shadow-md transition-all cursor-pointer group" + style={{ + borderLeft: ticket.source_email_address + ? `4px solid ${ticket.source_email_address.color}` + : undefined + }} >
@@ -247,6 +252,15 @@ const Tickets: React.FC = () => { )} + {ticket.source_email_address && ( + + {ticket.source_email_address.display_name} + + )} + {ticket.creatorFullName || ticket.creatorEmail} diff --git a/frontend/src/pages/marketing/PrivacyPolicyPage.tsx b/frontend/src/pages/marketing/PrivacyPolicyPage.tsx new file mode 100644 index 0000000..bcb72f5 --- /dev/null +++ b/frontend/src/pages/marketing/PrivacyPolicyPage.tsx @@ -0,0 +1,187 @@ +import React from 'react'; + +const PrivacyPolicyPage: React.FC = () => { + return ( +
+ {/* Header Section */} +
+
+

+ Privacy Policy +

+

+ Last updated: December 1, 2025 +

+
+
+ + {/* Content Section */} +
+
+
+ +

1. Introduction

+

+ Welcome to SmoothSchedule. We respect your privacy and are committed to protecting your personal data. This Privacy Policy explains how we collect, use, disclose, and safeguard your information when you use our scheduling platform and services. +

+ +

2. Information We Collect

+ +

2.1 Information You Provide

+

+ We collect information you directly provide to us, including: +

+
    +
  • Account information (name, email, password, phone number)
  • +
  • Business information (business name, subdomain, industry)
  • +
  • Payment information (processed securely through third-party payment processors)
  • +
  • Customer data you input into the platform (appointments, resources, services)
  • +
  • Communications with our support team
  • +
+ +

2.2 Automatically Collected Information

+

+ When you use our Service, we automatically collect: +

+
    +
  • Log data (IP address, browser type, device information, operating system)
  • +
  • Usage data (pages visited, features used, time spent on platform)
  • +
  • Cookie data (session cookies, preference cookies)
  • +
  • Performance and error data for service improvement
  • +
+ +

3. How We Use Your Information

+

+ We use the collected information for: +

+
    +
  • Providing and maintaining the Service
  • +
  • Processing your transactions and managing subscriptions
  • +
  • Sending you service updates, security alerts, and administrative messages
  • +
  • Responding to your inquiries and providing customer support
  • +
  • Improving and optimizing our Service
  • +
  • Detecting and preventing fraud and security issues
  • +
  • Complying with legal obligations
  • +
  • Sending marketing communications (with your consent)
  • +
+ +

4. Data Sharing and Disclosure

+ +

4.1 We Share Data With:

+
    +
  • Service Providers: Third-party vendors who help us provide the Service (hosting, payment processing, analytics)
  • +
  • Business Transfers: In connection with any merger, sale, or acquisition of all or part of our company
  • +
  • Legal Requirements: When required by law, court order, or legal process
  • +
  • Protection of Rights: To protect our rights, property, or safety, or that of our users
  • +
+ +

4.2 We Do NOT:

+
    +
  • Sell your personal data to third parties
  • +
  • Share your data for third-party marketing without consent
  • +
  • Access your customer data except for support or technical purposes
  • +
+ +

5. Data Security

+

+ We implement industry-standard security measures to protect your data: +

+
    +
  • Encryption of data in transit (TLS/SSL)
  • +
  • Encryption of sensitive data at rest
  • +
  • Regular security audits and vulnerability assessments
  • +
  • Access controls and authentication mechanisms
  • +
  • Regular backups and disaster recovery procedures
  • +
+

+ However, no method of transmission over the Internet is 100% secure. While we strive to protect your data, we cannot guarantee absolute security. +

+ +

6. Data Retention

+

+ We retain your personal data for as long as necessary to provide the Service and fulfill the purposes described in this policy. When you cancel your account, we retain your data for 30 days to allow for account reactivation. After this period, your personal data may be anonymized and aggregated for internal analytics and service improvement purposes. Anonymized data cannot be used to identify you personally and cannot be retrieved or attributed to any person or account. We may also retain certain data if required for legal or legitimate business purposes. +

+ +

7. Your Rights and Choices

+

+ Depending on your location, you may have the following rights: +

+
    +
  • Access: Request a copy of your personal data
  • +
  • Correction: Update or correct inaccurate data
  • +
  • Deletion: Request deletion of your personal data
  • +
  • Portability: Receive your data in a portable format
  • +
  • Objection: Object to certain data processing activities
  • +
  • Restriction: Request restriction of data processing
  • +
  • Withdraw Consent: Withdraw previously given consent
  • +
+

+ To exercise these rights, please contact us at privacy@smoothschedule.com. +

+ +

8. Cookies and Tracking

+

+ We use cookies and similar tracking technologies to: +

+
    +
  • Maintain your session and keep you logged in
  • +
  • Remember your preferences and settings
  • +
  • Analyze usage patterns and improve our Service
  • +
  • Provide personalized content and features
  • +
+

+ You can control cookies through your browser settings, but disabling cookies may affect your ability to use certain features of the Service. +

+ +

9. Third-Party Services

+

+ Our Service may contain links to third-party websites or integrate with third-party services (OAuth providers, payment processors). We are not responsible for the privacy practices of these third parties. We encourage you to review their privacy policies before providing any personal information. +

+ +

10. Children's Privacy

+

+ Our Service is not intended for children under 13 years of age. We do not knowingly collect personal information from children under 13. If you believe we have collected data from a child under 13, please contact us immediately so we can delete it. +

+ +

11. International Data Transfers

+

+ Your information may be transferred to and processed in countries other than your country of residence. These countries may have different data protection laws. We ensure appropriate safeguards are in place to protect your data in accordance with this Privacy Policy. +

+ +

12. California Privacy Rights

+

+ If you are a California resident, you have additional rights under the California Consumer Privacy Act (CCPA), including the right to know what personal information we collect, the right to delete your information, and the right to opt-out of the sale of your information (which we do not do). +

+ +

13. GDPR Compliance

+

+ If you are in the European Economic Area (EEA), we process your personal data based on legal grounds such as consent, contract performance, legal obligations, or legitimate interests. You have rights under the General Data Protection Regulation (GDPR) including the right to lodge a complaint with a supervisory authority. +

+ +

14. Changes to This Privacy Policy

+

+ We may update this Privacy Policy from time to time. We will notify you of material changes by posting the new policy on this page and updating the "Last updated" date. We encourage you to review this Privacy Policy periodically. +

+ +

15. Contact Us

+

+ If you have any questions about this Privacy Policy or our data practices, please contact us: +

+

+ Email: privacy@smoothschedule.com +

+

+ Data Protection Officer: dpo@smoothschedule.com +

+

+ Website: https://smoothschedule.com/contact +

+ +
+
+
+
+ ); +}; + +export default PrivacyPolicyPage; diff --git a/frontend/src/pages/marketing/TermsOfServicePage.tsx b/frontend/src/pages/marketing/TermsOfServicePage.tsx new file mode 100644 index 0000000..bd87d22 --- /dev/null +++ b/frontend/src/pages/marketing/TermsOfServicePage.tsx @@ -0,0 +1,136 @@ +import React from 'react'; + +const TermsOfServicePage: React.FC = () => { + return ( +
+ {/* Header Section */} +
+
+

+ Terms of Service +

+

+ Last updated: December 1, 2025 +

+
+
+ + {/* Content Section */} +
+
+
+ +

1. Acceptance of Terms

+

+ By accessing and using SmoothSchedule ("the Service"), you accept and agree to be bound by the terms and provision of this agreement. If you do not agree to these Terms of Service, please do not use the Service. +

+ +

2. Description of Service

+

+ SmoothSchedule is a multi-tenant scheduling platform that enables businesses to manage appointments, resources, services, and customer interactions. The Service is provided on a subscription basis with various pricing tiers. +

+ +

3. User Accounts

+

+ To use the Service, you must: +

+
    +
  • Create an account with accurate and complete information
  • +
  • Maintain the security of your account credentials
  • +
  • Notify us immediately of any unauthorized access
  • +
  • Be responsible for all activities under your account
  • +
+ +

4. Acceptable Use

+

+ You agree not to use the Service to: +

+
    +
  • Violate any applicable laws or regulations
  • +
  • Infringe on intellectual property rights
  • +
  • Transmit malicious code or interfere with the Service
  • +
  • Attempt to gain unauthorized access to any part of the Service
  • +
  • Use the Service for any fraudulent or illegal purpose
  • +
+ +

5. Subscriptions and Payments

+

+ Subscription terms: +

+
    +
  • Subscriptions are billed in advance on a recurring basis
  • +
  • You may cancel your subscription at any time
  • +
  • No refunds are provided for partial subscription periods
  • +
  • We reserve the right to change pricing with 30 days notice
  • +
  • Failed payments may result in service suspension
  • +
+ +

6. Trial Period

+

+ We may offer a free trial period. At the end of the trial, your subscription will automatically convert to a paid plan unless you cancel. Trial terms may vary and are subject to change. +

+ +

7. Data and Privacy

+

+ Your use of the Service is also governed by our Privacy Policy. We collect, use, and protect your data as described in that policy. You retain ownership of all data you input into the Service. +

+ +

8. Service Availability

+

+ While we strive for 99.9% uptime, we do not guarantee uninterrupted access to the Service. We may perform maintenance, updates, or modifications that temporarily affect availability. We are not liable for any downtime or service interruptions. +

+ +

9. Intellectual Property

+

+ The Service, including all software, designs, text, graphics, and other content, is owned by SmoothSchedule and protected by copyright, trademark, and other intellectual property laws. You may not copy, modify, distribute, or create derivative works without our express written permission. +

+ +

10. Termination

+

+ We may terminate or suspend your account and access to the Service at any time, with or without cause, with or without notice. Upon termination, your right to use the Service will immediately cease. We will retain your data for 30 days after termination, after which it may be permanently deleted. +

+ +

11. Limitation of Liability

+

+ To the maximum extent permitted by law, SmoothSchedule shall not be liable for any indirect, incidental, special, consequential, or punitive damages, or any loss of profits or revenues, whether incurred directly or indirectly, or any loss of data, use, goodwill, or other intangible losses resulting from your use of the Service. +

+ +

12. Warranty Disclaimer

+

+ The Service is provided "as is" and "as available" without warranties of any kind, either express or implied, including but not limited to implied warranties of merchantability, fitness for a particular purpose, or non-infringement. +

+ +

13. Indemnification

+

+ You agree to indemnify and hold harmless SmoothSchedule, its officers, directors, employees, and agents from any claims, damages, losses, liabilities, and expenses (including legal fees) arising from your use of the Service or violation of these Terms. +

+ +

14. Changes to Terms

+

+ We reserve the right to modify these Terms at any time. We will notify you of material changes via email or through the Service. Your continued use of the Service after such changes constitutes acceptance of the new Terms. +

+ +

15. Governing Law

+

+ These Terms shall be governed by and construed in accordance with the laws of the jurisdiction in which SmoothSchedule is registered, without regard to its conflict of law provisions. +

+ +

16. Contact Us

+

+ If you have any questions about these Terms of Service, please contact us at: +

+

+ Email: legal@smoothschedule.com +

+

+ Website: https://smoothschedule.com/contact +

+ +
+
+
+
+ ); +}; + +export default TermsOfServicePage; diff --git a/frontend/src/pages/platform/PlatformEmailAddresses.tsx b/frontend/src/pages/platform/PlatformEmailAddresses.tsx new file mode 100644 index 0000000..184e974 --- /dev/null +++ b/frontend/src/pages/platform/PlatformEmailAddresses.tsx @@ -0,0 +1,34 @@ +/** + * Platform Email Addresses Management Page + * Allows platform admins to manage platform-wide email addresses hosted on mail.talova.net + */ + +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Mail } from 'lucide-react'; +import PlatformEmailAddressManager from '../../components/PlatformEmailAddressManager'; + +const PlatformEmailAddresses: React.FC = () => { + const { t } = useTranslation(); + + return ( +
+ {/* Header */} +
+

+ + Platform Email Addresses +

+

+ Manage platform-wide email addresses hosted on mail.talova.net. + These addresses are used for platform-level support and are automatically synced to the mail server. +

+
+ + {/* Email Address Manager */} + +
+ ); +}; + +export default PlatformEmailAddresses; diff --git a/frontend/src/pages/platform/PlatformSettings.tsx b/frontend/src/pages/platform/PlatformSettings.tsx index 1ed4f2b..909b01d 100644 --- a/frontend/src/pages/platform/PlatformSettings.tsx +++ b/frontend/src/pages/platform/PlatformSettings.tsx @@ -14,7 +14,6 @@ import { Loader2, Eye, EyeOff, - RefreshCw, Layers, Plus, Pencil, @@ -26,16 +25,12 @@ import { Users, ExternalLink, Mail, - Clock, - Server, - Play, - ChevronDown, - ChevronUp, } from 'lucide-react'; import { usePlatformSettings, useUpdateStripeKeys, useValidateStripeKeys, + useUpdateGeneralSettings, useSubscriptionPlans, useCreateSubscriptionPlan, useUpdateSubscriptionPlan, @@ -48,15 +43,7 @@ import { usePlatformOAuthSettings, useUpdatePlatformOAuthSettings, } from '../../hooks/usePlatformOAuth'; -import { - useTicketEmailSettings, - useUpdateTicketEmailSettings, - useTestImapConnection, - useTestSmtpConnection, - useFetchEmailsNow, -} from '../../hooks/useTicketEmailSettings'; -import { Send, Wand2 } from 'lucide-react'; -import EmailConfigWizard from '../../components/EmailConfigWizard'; +import { Link } from 'react-router-dom'; type TabType = 'general' | 'stripe' | 'tiers' | 'oauth'; @@ -120,96 +107,26 @@ const PlatformSettings: React.FC = () => { const GeneralSettingsTab: React.FC = () => { const { t } = useTranslation(); - const { data: emailSettings, isLoading, error, refetch } = useTicketEmailSettings(); - const updateMutation = useUpdateTicketEmailSettings(); - const testImapMutation = useTestImapConnection(); - const testSmtpMutation = useTestSmtpConnection(); - const fetchNowMutation = useFetchEmailsNow(); + const { data: settings, isLoading } = usePlatformSettings(); + const updateGeneralSettings = useUpdateGeneralSettings(); + const [emailCheckInterval, setEmailCheckInterval] = useState(5); + const [hasChanges, setHasChanges] = useState(false); - const [showWizard, setShowWizard] = useState(false); - - const [formData, setFormData] = useState({ - // IMAP settings - imap_host: '', - imap_port: 993, - imap_use_ssl: true, - imap_username: '', - imap_password: '', - imap_folder: 'INBOX', - // SMTP settings - smtp_host: '', - smtp_port: 587, - smtp_use_tls: true, - smtp_use_ssl: false, - smtp_username: '', - smtp_password: '', - smtp_from_email: '', - smtp_from_name: '', - // General settings - support_email_address: '', - support_email_domain: '', - is_enabled: false, - delete_after_processing: true, - check_interval_seconds: 60, - }); - - const [showImapPassword, setShowImapPassword] = useState(false); - const [showSmtpPassword, setShowSmtpPassword] = useState(false); - const [isImapExpanded, setIsImapExpanded] = useState(false); - const [isSmtpExpanded, setIsSmtpExpanded] = useState(false); - - // Update form when settings load + // Sync local state with settings when loaded React.useEffect(() => { - if (emailSettings) { - setFormData({ - // IMAP settings - imap_host: emailSettings.imap_host || '', - imap_port: emailSettings.imap_port || 993, - imap_use_ssl: emailSettings.imap_use_ssl ?? true, - imap_username: emailSettings.imap_username || '', - imap_password: '', // Don't prefill password - imap_folder: emailSettings.imap_folder || 'INBOX', - // SMTP settings - smtp_host: emailSettings.smtp_host || '', - smtp_port: emailSettings.smtp_port || 587, - smtp_use_tls: emailSettings.smtp_use_tls ?? true, - smtp_use_ssl: emailSettings.smtp_use_ssl ?? false, - smtp_username: emailSettings.smtp_username || '', - smtp_password: '', // Don't prefill password - smtp_from_email: emailSettings.smtp_from_email || '', - smtp_from_name: emailSettings.smtp_from_name || '', - // General settings - support_email_address: emailSettings.support_email_address || '', - support_email_domain: emailSettings.support_email_domain || '', - is_enabled: emailSettings.is_enabled ?? false, - delete_after_processing: emailSettings.delete_after_processing ?? true, - check_interval_seconds: emailSettings.check_interval_seconds || 60, - }); + if (settings?.email_check_interval_minutes) { + setEmailCheckInterval(settings.email_check_interval_minutes); } - }, [emailSettings]); + }, [settings]); - const handleSave = async () => { - // Only send passwords if they were changed - const dataToSend = { ...formData }; - if (!dataToSend.imap_password) { - delete (dataToSend as any).imap_password; - } - if (!dataToSend.smtp_password) { - delete (dataToSend as any).smtp_password; - } - await updateMutation.mutateAsync(dataToSend); + const handleIntervalChange = (value: number) => { + setEmailCheckInterval(value); + setHasChanges(value !== settings?.email_check_interval_minutes); }; - const handleTestImap = async () => { - await testImapMutation.mutateAsync(); - }; - - const handleTestSmtp = async () => { - await testSmtpMutation.mutateAsync(); - }; - - const handleFetchNow = async () => { - await fetchNowMutation.mutateAsync(); + const handleSaveInterval = async () => { + await updateGeneralSettings.mutateAsync({ email_check_interval_minutes: emailCheckInterval }); + setHasChanges(false); }; if (isLoading) { @@ -220,611 +137,101 @@ const GeneralSettingsTab: React.FC = () => { ); } - if (error) { - return ( -
-
- - Failed to load email settings -
-
- ); - } - - // Show wizard if requested - if (showWizard) { - return ( -
- { - setShowWizard(false); - refetch(); - }} - onCancel={() => setShowWizard(false)} - initialEmail={emailSettings?.imap_username || ''} - /> -
- ); - } - return (
- {/* Email Processing Status */} + {/* Email Settings Card */}
-
-

- - {t('platform.settings.emailProcessing', 'Support Email Processing')} -

- + + Manage Email Addresses +
+
-
-
- {emailSettings?.is_enabled ? ( - - ) : ( - - )} -
-

Status

-

- {emailSettings?.is_enabled ? 'Enabled' : 'Disabled'} -

-
-
- -
- {emailSettings?.is_imap_configured ? ( - - ) : ( - - )} -
-

IMAP (Inbound)

-

- {emailSettings?.is_imap_configured ? 'Configured' : 'Not configured'} -

-
-
- -
- {emailSettings?.is_smtp_configured ? ( - - ) : ( - - )} -
-

SMTP (Outbound)

-

- {emailSettings?.is_smtp_configured ? 'Configured' : 'Not configured'} -

-
-
- -
- -
-

Last Check

-

- {emailSettings?.last_check_at - ? new Date(emailSettings.last_check_at).toLocaleString() - : 'Never'} -

+ {/* Email Check Interval */} +
+

+ + Email Polling Settings +

+
+
+ +
+ + {hasChanges && ( + + )}
+

+ This controls how often the system checks for incoming emails to create support tickets. +

- - {emailSettings?.last_error && ( -
-

- Last Error: {emailSettings.last_error} + {updateGeneralSettings.isSuccess && ( +

+

+ + Email polling interval updated

)} - -
- Emails processed: {emailSettings?.emails_processed_count || 0} - Check interval: {emailSettings?.check_interval_seconds || 60}s -
- {/* IMAP Configuration */} -
- - - {isImapExpanded && ( -
- {/* Enable/Disable Toggle */} -
-
-

Enable Email Processing

-

- Automatically fetch and process incoming support emails -

-
- -
- - {/* Server Settings */} -
-
- - setFormData((prev) => ({ ...prev, imap_host: e.target.value }))} - placeholder="mail.talova.net" - 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" - /> -
- -
-
- - setFormData((prev) => ({ ...prev, imap_port: parseInt(e.target.value) || 993 }))} - 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" - /> -
-
- -
-
-
- -
-
- - setFormData((prev) => ({ ...prev, imap_username: e.target.value }))} - placeholder="support@yourdomain.com" - 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" - /> -
- -
- -
- setFormData((prev) => ({ ...prev, imap_password: e.target.value }))} - placeholder={emailSettings?.imap_password_masked || 'Enter password'} - className="w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" - /> - -
-
-
- -
-
- - setFormData((prev) => ({ ...prev, imap_folder: e.target.value }))} - placeholder="INBOX" - 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" - /> -
- -
- - setFormData((prev) => ({ ...prev, support_email_domain: e.target.value }))} - placeholder="mail.talova.net" - 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" - /> -

- Domain for reply-to addresses (e.g., support+ticket-123@domain) -

-
-
- - {/* Test IMAP Button */} -
- -
- - {testImapMutation.isSuccess && ( -
-

- {testImapMutation.data?.success ? ( - - ) : ( - - )} - {testImapMutation.data?.message} -

-
- )} -
- )} -
- - {/* SMTP Configuration */} -
- - - {isSmtpExpanded && ( -
- {/* Server Settings */} -
-
- - setFormData((prev) => ({ ...prev, smtp_host: e.target.value }))} - placeholder="smtp.example.com" - 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" - /> -
- -
-
- - setFormData((prev) => ({ ...prev, smtp_port: parseInt(e.target.value) || 587 }))} - 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" - /> -
-
- -
-
- -
-
-
- -
-
- - setFormData((prev) => ({ ...prev, smtp_username: e.target.value }))} - placeholder="user@example.com" - 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" - /> -
- -
- -
- setFormData((prev) => ({ ...prev, smtp_password: e.target.value }))} - placeholder={emailSettings?.smtp_password_masked || 'Enter password'} - className="w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" - /> - -
-
-
- -
-
- - setFormData((prev) => ({ ...prev, smtp_from_email: e.target.value }))} - placeholder="support@yourdomain.com" - 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" - /> -
- -
- - setFormData((prev) => ({ ...prev, smtp_from_name: e.target.value }))} - placeholder="SmoothSchedule Support" - 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" - /> -
-
- - {/* Test SMTP Button */} -
- -
- - {testSmtpMutation.isSuccess && ( -
-

- {testSmtpMutation.data?.success ? ( - - ) : ( - - )} - {testSmtpMutation.data?.message} -

-
- )} -
- )} -
- - {/* Processing Settings */} + {/* Platform Info */}
-

+

- {t('platform.settings.processingSettings', 'Processing Settings')} + {t('platform.settings.platformInfo', 'Platform Information')}

- -
-

Email Fetching

- -
-
- - setFormData((prev) => ({ ...prev, check_interval_seconds: parseInt(e.target.value) || 60 }))} - 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" - /> -

- How often to check for new emails (10-3600 seconds) -

-
- -
- -
+
+
+

Mail Server

+

mail.talova.net

- - {/* Actions */} -
- - - +
+

Email Domain

+

smoothschedule.com

- - {/* Status Messages */} - {updateMutation.isSuccess && ( -
-

- - Settings saved successfully -

-
- )} - - {updateMutation.isError && ( -
-

- Failed to save settings. Please try again. -

-
- )} - - {fetchNowMutation.isSuccess && ( -
-

- - {fetchNowMutation.data?.message} -

-
- )}
@@ -1290,6 +697,16 @@ const PlanRow: React.FC = ({ plan, onEdit, onDelete }) => { Hidden )} + {plan.is_most_popular && ( + + Popular + + )} + {!plan.show_price && ( + + Price Hidden + + )} {plan.business_tier && ( {plan.business_tier} @@ -1350,6 +767,22 @@ const PlanModal: React.FC = ({ plan, onSave, onClose, isLoading price_yearly: plan?.price_yearly ? parseFloat(plan.price_yearly) : undefined, business_tier: plan?.business_tier || '', features: plan?.features || [], + limits: plan?.limits || { + max_users: 5, + max_resources: 10, + max_appointments: 100, + max_automated_tasks: 5, + }, + permissions: plan?.permissions || { + can_accept_payments: false, + sms_reminders: false, + advanced_reporting: false, + priority_support: false, + can_use_custom_domain: false, + can_create_plugins: false, + can_white_label: false, + can_api_access: false, + }, transaction_fee_percent: plan?.transaction_fee_percent ? parseFloat(plan.transaction_fee_percent) : 0, @@ -1358,6 +791,8 @@ const PlanModal: React.FC = ({ plan, onSave, onClose, isLoading : 0, is_active: plan?.is_active ?? true, is_public: plan?.is_public ?? true, + is_most_popular: plan?.is_most_popular ?? false, + show_price: plan?.show_price ?? true, create_stripe_product: false, stripe_product_id: plan?.stripe_product_id || '', stripe_price_id: plan?.stripe_price_id || '', @@ -1382,6 +817,26 @@ const PlanModal: React.FC = ({ plan, onSave, onClose, isLoading })); }; + const handleLimitChange = (key: string, value: string) => { + setFormData((prev) => ({ + ...prev, + limits: { + ...prev.limits, + [key]: parseInt(value) || 0, + }, + })); + }; + + const handlePermissionChange = (key: string, value: boolean) => { + setFormData((prev) => ({ + ...prev, + permissions: { + ...prev.permissions, + [key]: value, + }, + })); + }; + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); onSave(formData); @@ -1389,7 +844,7 @@ const PlanModal: React.FC = ({ plan, onSave, onClose, isLoading return (
-
+

{plan ? 'Edit Plan' : 'Create Plan'} @@ -1399,146 +854,294 @@ const PlanModal: React.FC = ({ plan, onSave, onClose, isLoading

-
-
+ + {/* Basic Info */} +
+

+ Basic Information +

+
+
+ + setFormData((prev) => ({ ...prev, name: 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" + /> +
+
+ + +
+
- setFormData((prev) => ({ ...prev, name: e.target.value }))} - required +