From cde2544cf3d281fa7093dd13943a858a5f3b9d6f Mon Sep 17 00:00:00 2001 From: Yashwin Date: Tue, 14 Apr 2026 18:02:26 +0530 Subject: [PATCH] feat: implement SMTP configuration management for super admin and tenant levels --- src/components/layout/Sidebar.tsx | 12 + src/components/superadmin/SmtpConfigModal.tsx | 378 ++++++++++++++++++ src/pages/superadmin/SmtpConfig.tsx | 193 +++++++++ src/pages/tenant/SmtpSettings.tsx | 237 +++++++++++ src/routes/super-admin-routes.tsx | 5 + src/routes/tenant-admin-routes.tsx | 5 + src/services/smtp-config-service.ts | 57 +++ 7 files changed, 887 insertions(+) create mode 100644 src/components/superadmin/SmtpConfigModal.tsx create mode 100644 src/pages/superadmin/SmtpConfig.tsx create mode 100644 src/pages/tenant/SmtpSettings.tsx create mode 100644 src/services/smtp-config-service.ts diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 16057fa..3813f9a 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -69,6 +69,14 @@ const superAdminSystemMenu: MenuItem[] = [ }, { icon: FileText, label: "Audit Logs", path: "/audit-logs" }, { icon: Shield, label: "Audit Resources", path: "/audit-resource-types" }, + { + icon: Settings, + label: "Settings", + isGroup: true, + children: [ + { label: "SMTP Config", path: "/settings/smtp" }, + ], + }, ]; // Tenant Admin menu items @@ -202,6 +210,10 @@ const tenantAdminSystemMenu: MenuItem[] = [ { label: "Notification Templates", path: "/tenant/settings/notification-templates", + }, + { + label: "SMTP Settings", + path: "/tenant/settings/smtp", } ], requiredPermission: { resource: "tenants" }, diff --git a/src/components/superadmin/SmtpConfigModal.tsx b/src/components/superadmin/SmtpConfigModal.tsx new file mode 100644 index 0000000..21230d3 --- /dev/null +++ b/src/components/superadmin/SmtpConfigModal.tsx @@ -0,0 +1,378 @@ +import { useState, useEffect } from 'react'; +import { useForm, Controller } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { Modal, PrimaryButton, SecondaryButton, FormField, FormSelect } from '@/components/shared'; +import { smtpConfigService, type SmtpConfig } from '@/services/smtp-config-service'; +import { tenantService } from '@/services/tenant-service'; +import { showToast } from '@/utils/toast'; +import { Loader2, Shield, CheckCircle, XCircle } from 'lucide-react'; + +const smtpObjectSchema = z.object({ + id: z.string().optional(), + scope: z.enum(['super_admin', 'tenant']), + tenant_id: z.string().nullable().optional(), + provider: z.string().min(1, 'Provider is required'), + host: z.string().min(1, 'Host is required'), + port: z.number().min(1, 'Port is required'), + secure: z.boolean(), + username: z.string().min(1, 'Username is required'), + password: z.string().optional(), + from_name: z.string().nullable().optional(), + from_email: z.string().email('Invalid email').nullable().optional().or(z.literal('')), + is_active: z.boolean() +}); + +const smtpSchema = smtpObjectSchema.refine((data) => { + if (data.scope === 'tenant' && !data.tenant_id) { + return false; + } + return true; +}, { + message: "Tenant is required for tenant specific configuration", + path: ["tenant_id"] +}); + +type SmtpFormValues = z.infer; + +interface SmtpConfigModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: () => Promise; + config?: SmtpConfig | null; +} + +export const SmtpConfigModal = ({ isOpen, onClose, onSubmit, config }: SmtpConfigModalProps) => { + const [tenants, setTenants] = useState<{ value: string; label: string }[]>([]); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isTesting, setIsTesting] = useState(false); + const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); + + const { + control, + handleSubmit, + reset, + watch, + setValue, + formState: { errors } + } = useForm({ + resolver: zodResolver(smtpSchema), + defaultValues: { + scope: 'tenant', + provider: 'smtp', + host: '', + port: 587, + secure: false, + username: '', + password: '', + from_name: '', + from_email: '', + is_active: true, + tenant_id: '' + } + }); + + const scope = watch('scope'); + + useEffect(() => { + if (isOpen) { + if (config) { + reset({ + ...config, + password: '', + tenant_id: config.tenant_id || '' + }); + } else { + reset({ + scope: 'tenant', + provider: 'smtp', + host: '', + port: 587, + secure: false, + username: '', + password: '', + from_name: '', + from_email: '', + is_active: true, + tenant_id: '' + }); + } + setTestResult(null); + } + }, [config, isOpen, reset]); + + useEffect(() => { + if (isOpen && scope === 'tenant') { + loadTenants(); + } + }, [isOpen, scope]); + + const loadTenants = async () => { + try { + const res = await tenantService.getAll(1, 100); + if (res.success) { + setTenants(res.data.map((t: any) => ({ value: t.id, label: t.name }))); + } + } catch (err) { + showToast.error('Failed to load tenants'); + } + }; + + const handleTestConnection = async () => { + const data = watch(); + setIsTesting(true); + setTestResult(null); + try { + const res = await smtpConfigService.adminTestConnection(data); + setTestResult({ + success: res.success, + message: res.message + }); + if (res.success) { + showToast.success('SMTP connection verified'); + } else { + showToast.error('SMTP connection failed'); + } + } catch (err: any) { + const errorData = err.response?.data?.error; + const errorMsg = typeof errorData === 'string' ? errorData : errorData?.message; + setTestResult({ + success: false, + message: errorMsg || err.response?.data?.message || 'Connection test failed' + }); + } finally { + setIsTesting(false); + } + }; + + const onFormSubmit = async (values: SmtpFormValues) => { + setIsSubmitting(true); + try { + // If it's a new config, ensure password is not empty + if (!config && (!values.password || values.password.trim() === '')) { + showToast.error('Password is required for new configurations'); + setIsSubmitting(false); + return; + } + + await smtpConfigService.adminUpsertConfig(values); + showToast.success('SMTP configuration saved'); + await onSubmit(); + onClose(); + } catch (err: any) { + const errorData = err.response?.data?.error; + const errorMsg = typeof errorData === 'string' ? errorData : errorData?.message; + showToast.error(errorMsg || err.response?.data?.message || 'Failed to save configuration'); + } finally { + setIsSubmitting(false); + } + }; + + return ( + +
+
+ ( + { + field.onChange(val); + if (val === 'super_admin') setValue('tenant_id', ''); + }} + options={[ + { value: 'super_admin', label: 'Global (Super Admin)' }, + { value: 'tenant', label: 'Tenant Specific' } + ]} + error={errors.scope?.message} + required + /> + )} + /> + + {scope === 'tenant' && ( + ( + + )} + /> + )} +
+ +
+
+ ( + + )} + /> +
+ ( + field.onChange(e.target.value ? Number(e.target.value) : undefined)} + placeholder="587" + error={errors.port?.message} + required + /> + )} + /> +
+ +
+ ( + + )} + /> +
+ ( + + )} + /> +
+ +
+ ( + + )} + /> + ( + + )} + /> +
+ +
+ ( + + )} + /> + ( + + )} + /> +
+ + {testResult && ( +
+ {testResult.success ? : } + {testResult.message} +
+ )} + +
+ + {isTesting ? ( + + ) : ( + + )} + Test Connection + + +
+ Cancel + + {isSubmitting ? 'Saving...' : (config ? 'Update Configuration' : 'Create Configuration')} + +
+
+
+
+ ); +}; diff --git a/src/pages/superadmin/SmtpConfig.tsx b/src/pages/superadmin/SmtpConfig.tsx new file mode 100644 index 0000000..ed52acb --- /dev/null +++ b/src/pages/superadmin/SmtpConfig.tsx @@ -0,0 +1,193 @@ +import { useState, useEffect } from 'react'; +import { Layout } from '@/components/layout/Layout'; +import { + DataTable, + Pagination, + StatusBadge, + ActionDropdown, + PrimaryButton, + DeleteConfirmationModal, + type Column +} from '@/components/shared'; +import { Plus, Server, Globe, Building } from 'lucide-react'; +import { smtpConfigService, type SmtpConfig } from '@/services/smtp-config-service'; +import { SmtpConfigModal } from '@/components/superadmin/SmtpConfigModal'; +import { showToast } from '@/utils/toast'; + +const SmtpConfigPage = () => { + const [configs, setConfigs] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isModalOpen, setIsModalOpen] = useState(false); + const [selectedConfig, setSelectedConfig] = useState(null); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + // Pagination state + const [currentPage, setCurrentPage] = useState(1); + const [limit, setLimit] = useState(10); + const [totalItems, setTotalItems] = useState(0); + + const fetchConfigs = async () => { + setIsLoading(true); + try { + const res = await smtpConfigService.listAll({ + offset: (currentPage - 1) * limit, + limit: limit + }); + if (res.success) { + setConfigs(res.data); + // Assuming the API would return total items if we had many, + // for now let's just use the length or a fixed number + setTotalItems(res.data.length); + } + } catch (err) { + showToast.error('Failed to load SMTP configurations'); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchConfigs(); + }, [currentPage, limit]); + + const handleEdit = (config: SmtpConfig) => { + setSelectedConfig(config); + setIsModalOpen(true); + }; + + const handleDelete = (config: SmtpConfig) => { + setSelectedConfig(config); + setDeleteModalOpen(true); + }; + + const confirmDelete = async () => { + if (!selectedConfig?.id) return; + setIsDeleting(true); + try { + await smtpConfigService.deleteConfig(selectedConfig.id); + showToast.success('Configuration deleted'); + setDeleteModalOpen(false); + fetchConfigs(); + } catch (err) { + showToast.error('Failed to delete configuration'); + } finally { + setIsDeleting(false); + } + }; + + const columns: Column[] = [ + { + key: 'scope', + label: 'Scope', + render: (config) => ( +
+ {config.scope === 'super_admin' ? ( + + ) : ( + + )} + + {config.scope === 'super_admin' ? 'Global' : config.tenant_name || 'Tenant'} + +
+ ) + }, + { + key: 'host', + label: 'Server', + render: (config) => ( +
+ + {config.host}:{config.port} +
+ ) + }, + { + key: 'from_email', + label: 'Sender', + render: (config) => ( +
+ {config.from_name || 'N/A'} + {config.from_email || 'N/A'} +
+ ) + }, + { + key: 'is_active', + label: 'Status', + render: (config) => ( + + {config.is_active ? 'Active' : 'Inactive'} + + ) + }, + { + key: 'actions', + label: 'Actions', + align: 'right', + render: (config) => ( + handleEdit(config)} + onDelete={() => handleDelete(config)} + /> + ) + } + ]; + + return ( + { setSelectedConfig(null); setIsModalOpen(true); }}> + + Add Configuration + + ) + }} + > +
+ item.id!} + emptyMessage="No SMTP configurations found" + /> + + {totalItems > limit && ( + + )} +
+ + setIsModalOpen(false)} + onSubmit={fetchConfigs} + config={selectedConfig} + /> + + setDeleteModalOpen(false)} + onConfirm={confirmDelete} + title="Delete SMTP Configuration" + message="Are you sure you want to delete this SMTP configuration? This action cannot be undone." + itemName={selectedConfig?.scope === 'super_admin' ? 'Global Config' : selectedConfig?.tenant_name || 'Tenant Config'} + isLoading={isDeleting} + /> +
+ ); +}; + +export default SmtpConfigPage; diff --git a/src/pages/tenant/SmtpSettings.tsx b/src/pages/tenant/SmtpSettings.tsx new file mode 100644 index 0000000..2a837a6 --- /dev/null +++ b/src/pages/tenant/SmtpSettings.tsx @@ -0,0 +1,237 @@ +import { useState, useEffect } from 'react'; +import { Layout } from "@/components/layout/Layout"; +import { Loader2, Info, Shield } from "lucide-react"; +import { useAppSelector } from "@/hooks/redux-hooks"; +import { smtpConfigService, type SmtpConfig } from "@/services/smtp-config-service"; +import { showToast } from "@/utils/toast"; +import { PrimaryButton, SecondaryButton, FormField, StatusBadge } from "@/components/shared"; + +const SmtpSettings = () => { + const tenantId = useAppSelector((state) => state.auth.tenantId); + const [config, setConfig] = useState>({ + host: '', + port: 587, + secure: false, + username: '', + password: '', + from_name: '', + from_email: '', + is_active: true, + scope: 'tenant' + }); + + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [isTesting, setIsTesting] = useState(false); + const [hasExistingConfig, setHasExistingConfig] = useState(false); + + useEffect(() => { + fetchConfig(); + }, [tenantId]); + + const fetchConfig = async () => { + if (!tenantId) return; + setIsLoading(true); + try { + const res = await smtpConfigService.getConfig(tenantId); + if (res.success && res.data) { + setConfig({ + ...res.data, + password: '' // Don't show password + }); + setHasExistingConfig(true); + } else { + setHasExistingConfig(false); + } + } catch (err) { + // It's fine if no config exists, it will fallback to global + } finally { + setIsLoading(false); + } + }; + + const handleTestConnection = async () => { + setIsTesting(true); + try { + const res = await smtpConfigService.testConnection(config); + if (res.success) { + showToast.success('SMTP connection verified successfully'); + } else { + showToast.error(`Connection failed: ${res.message}`); + } + } catch (err: any) { + const errorData = err.response?.data?.error; + const errorMsg = typeof errorData === 'string' ? errorData : errorData?.message; + showToast.error(errorMsg || err.response?.data?.message || 'Connection test failed'); + } finally { + setIsTesting(false); + } + }; + + const handleSave = async () => { + setIsSaving(true); + try { + await smtpConfigService.upsertConfig({ + ...config, + tenant_id: tenantId || undefined, + scope: 'tenant' + }); + showToast.success('SMTP settings saved successfully'); + setHasExistingConfig(true); + } catch (err: any) { + const errorData = err.response?.data?.error; + const errorMsg = typeof errorData === 'string' ? errorData : errorData?.message; + showToast.error(errorMsg || err.response?.data?.message || 'Failed to save settings'); + } finally { + setIsSaving(false); + } + }; + + if (isLoading) { + return ( + +
+ +
+
+ ); + } + + return ( + +
+ {!hasExistingConfig && ( +
+ +
+

Using Platform Defaults

+

+ You haven't configured a custom SMTP. The platform is currently using the global fallback SMTP settings for sending your emails. + Configure your own settings below to send emails from your own domain. +

+
+
+ )} + +
+
+
+

Custom SMTP Configuration

+

Define your outgoing mail server details

+
+ {hasExistingConfig && ( + + {config.is_active ? 'Configured & Active' : 'Inactive'} + + )} +
+ +
+
+
+ setConfig({ ...config, host: e.target.value })} + placeholder="smtp.your-company.com" + /> +
+ setConfig({ ...config, port: parseInt(e.target.value) })} + placeholder="587" + /> +
+ +
+ + +
+ +
+ setConfig({ ...config, username: e.target.value })} + placeholder="notifications@yourdomain.com" + /> + setConfig({ ...config, password: e.target.value })} + placeholder={hasExistingConfig ? '••••••••' : 'Your SMTP password'} + helperText={hasExistingConfig ? "Leave blank to keep existing password" : ""} + /> +
+ +
+ setConfig({ ...config, from_name: e.target.value })} + placeholder="Your Company Notifications" + /> + setConfig({ ...config, from_email: e.target.value })} + placeholder="noreply@yourdomain.com" + /> +
+
+ +
+ + {isTesting ? ( + + ) : ( + + )} + Test Connection + + + + {isSaving ? "Saving..." : "Save Settings"} + +
+
+
+
+ ); +}; + +export default SmtpSettings; diff --git a/src/routes/super-admin-routes.tsx b/src/routes/super-admin-routes.tsx index cb6dd3b..abf3b6b 100644 --- a/src/routes/super-admin-routes.tsx +++ b/src/routes/super-admin-routes.tsx @@ -17,6 +17,7 @@ const Notifications = lazy(() => import("@/pages/tenant/Notifications")); const AuditLogResourceTypes = lazy(() => import("@/pages/superadmin/AuditLogResourceTypes")); const NotificationMaster = lazy(() => import("@/pages/superadmin/NotificationMaster")); const NotificationTemplateMaster = lazy(() => import("@/pages/superadmin/NotificationTemplateMaster")); +const SmtpConfig = lazy(() => import("@/pages/superadmin/SmtpConfig")); // Loading fallback component const RouteLoader = (): ReactElement => ( @@ -95,4 +96,8 @@ export const superAdminRoutes: RouteConfig[] = [ path: "/notification-templates", element: , }, + { + path: "/settings/smtp", + element: , + }, ]; diff --git a/src/routes/tenant-admin-routes.tsx b/src/routes/tenant-admin-routes.tsx index d17cf6d..07af6fb 100644 --- a/src/routes/tenant-admin-routes.tsx +++ b/src/routes/tenant-admin-routes.tsx @@ -34,6 +34,7 @@ const NotificationTemplates = lazy(() => import("@/pages/tenant/NotificationTemp const FilesList = lazy(() => import("@/pages/tenant/FilesList")); const FileView = lazy(() => import("@/pages/tenant/FileView")); const StorageDashboard = lazy(() => import("@/pages/tenant/StorageDashboard")); +const SmtpSettings = lazy(() => import("@/pages/tenant/SmtpSettings")); // Loading fallback component const RouteLoader = (): ReactElement => ( @@ -156,4 +157,8 @@ export const tenantAdminRoutes: RouteConfig[] = [ path: "/tenant/files/storage-dashboard", element: , }, + { + path: "/tenant/settings/smtp", + element: , + }, ]; diff --git a/src/services/smtp-config-service.ts b/src/services/smtp-config-service.ts new file mode 100644 index 0000000..610cdff --- /dev/null +++ b/src/services/smtp-config-service.ts @@ -0,0 +1,57 @@ +import apiClient from './api-client'; + +export interface SmtpConfig { + id?: string; + tenant_id: string | null; + scope: 'super_admin' | 'tenant'; + provider: string; + host: string; + port: number; + secure: boolean; + username: string; + password?: string; + from_name: string | null; + from_email: string | null; + is_active: boolean; + tenant_name?: string; +} + +class SmtpConfigService { + async getConfig(tenantId?: string) { + const params = tenantId ? { tenantId } : {}; + const response = await apiClient.get('/smtp-config', { params }); + return response.data; + } + + async listAll(params: any = {}) { + const response = await apiClient.get('/smtp-config/all', { params }); + return response.data; + } + + async upsertConfig(data: Partial) { + const response = await apiClient.post('/smtp-config', data); + return response.data; + } + + async adminUpsertConfig(data: Partial) { + const response = await apiClient.post('/smtp-config/setup', data); + return response.data; + } + + async testConnection(data: any) { + const response = await apiClient.post('/smtp-config/test', data); + return response.data; + } + + async adminTestConnection(data: any) { + const response = await apiClient.post('/smtp-config/admin/test', data); + return response.data; + } + + async deleteConfig(id: string) { + const response = await apiClient.delete(`/smtp-config/${id}`); + return response.data; + } +} + +export const smtpConfigService = new SmtpConfigService();