feat: implement SMTP configuration management for super admin and tenant levels
This commit is contained in:
parent
2381260190
commit
cde2544cf3
@ -69,6 +69,14 @@ const superAdminSystemMenu: MenuItem[] = [
|
|||||||
},
|
},
|
||||||
{ icon: FileText, label: "Audit Logs", path: "/audit-logs" },
|
{ icon: FileText, label: "Audit Logs", path: "/audit-logs" },
|
||||||
{ icon: Shield, label: "Audit Resources", path: "/audit-resource-types" },
|
{ 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
|
// Tenant Admin menu items
|
||||||
@ -202,6 +210,10 @@ const tenantAdminSystemMenu: MenuItem[] = [
|
|||||||
{
|
{
|
||||||
label: "Notification Templates",
|
label: "Notification Templates",
|
||||||
path: "/tenant/settings/notification-templates",
|
path: "/tenant/settings/notification-templates",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "SMTP Settings",
|
||||||
|
path: "/tenant/settings/smtp",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
requiredPermission: { resource: "tenants" },
|
requiredPermission: { resource: "tenants" },
|
||||||
|
|||||||
378
src/components/superadmin/SmtpConfigModal.tsx
Normal file
378
src/components/superadmin/SmtpConfigModal.tsx
Normal file
@ -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<typeof smtpObjectSchema>;
|
||||||
|
|
||||||
|
interface SmtpConfigModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: () => Promise<void>;
|
||||||
|
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<SmtpFormValues>({
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={config ? 'Edit SMTP Configuration' : 'New SMTP Configuration'}
|
||||||
|
maxWidth="xl"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit(onFormSubmit)} className="p-5 space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="scope"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormSelect
|
||||||
|
label="Scope"
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
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' && (
|
||||||
|
<Controller
|
||||||
|
name="tenant_id"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormSelect
|
||||||
|
label="Tenant"
|
||||||
|
value={field.value || ''}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
options={tenants}
|
||||||
|
error={errors.tenant_id?.message}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Controller
|
||||||
|
name="host"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormField
|
||||||
|
label="SMTP Host"
|
||||||
|
{...field}
|
||||||
|
placeholder="smtp.example.com"
|
||||||
|
error={errors.host?.message}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Controller
|
||||||
|
name="port"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormField
|
||||||
|
label="Port"
|
||||||
|
type="number"
|
||||||
|
{...field}
|
||||||
|
value={field.value?.toString()}
|
||||||
|
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
placeholder="587"
|
||||||
|
error={errors.port?.message}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Controller
|
||||||
|
name="secure"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<label className="flex items-center space-x-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-700">Use SSL/TLS</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1"></div>
|
||||||
|
<Controller
|
||||||
|
name="is_active"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<label className="flex items-center space-x-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-green-600 border-gray-300 rounded focus:ring-green-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-700">Active</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2 border-t border-gray-100">
|
||||||
|
<Controller
|
||||||
|
name="username"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormField
|
||||||
|
label="Username"
|
||||||
|
{...field}
|
||||||
|
placeholder="user@example.com"
|
||||||
|
error={errors.username?.message}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="password"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormField
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
{...field}
|
||||||
|
placeholder={config ? 'Leave blank to keep current' : '••••••••'}
|
||||||
|
error={errors.password?.message}
|
||||||
|
required={!config}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="from_name"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormField
|
||||||
|
label="Sender Name"
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ''}
|
||||||
|
placeholder="QAssure Notifications"
|
||||||
|
error={errors.from_name?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="from_email"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormField
|
||||||
|
label="Sender Email"
|
||||||
|
type="email"
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ''}
|
||||||
|
placeholder="noreply@qassure.ai"
|
||||||
|
error={errors.from_email?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{testResult && (
|
||||||
|
<div className={`p-3 rounded-lg flex items-start gap-3 ${testResult.success ? 'bg-green-50 text-green-700 border border-green-100' : 'bg-red-50 text-red-700 border border-red-100'}`}>
|
||||||
|
{testResult.success ? <CheckCircle className="w-5 h-5 mt-0.5 shrink-0" /> : <XCircle className="w-5 h-5 mt-0.5 shrink-0" />}
|
||||||
|
<span className="text-sm">{testResult.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={handleTestConnection}
|
||||||
|
disabled={isTesting || !watch('host') || !watch('username')}
|
||||||
|
>
|
||||||
|
{isTesting ? (
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Shield className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Test Connection
|
||||||
|
</SecondaryButton>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<SecondaryButton type="button" onClick={onClose}>Cancel</SecondaryButton>
|
||||||
|
<PrimaryButton type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? 'Saving...' : (config ? 'Update Configuration' : 'Create Configuration')}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
193
src/pages/superadmin/SmtpConfig.tsx
Normal file
193
src/pages/superadmin/SmtpConfig.tsx
Normal file
@ -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<SmtpConfig[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [selectedConfig, setSelectedConfig] = useState<SmtpConfig | null>(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<SmtpConfig>[] = [
|
||||||
|
{
|
||||||
|
key: 'scope',
|
||||||
|
label: 'Scope',
|
||||||
|
render: (config) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{config.scope === 'super_admin' ? (
|
||||||
|
<Globe className="w-4 h-4 text-blue-600" />
|
||||||
|
) : (
|
||||||
|
<Building className="w-4 h-4 text-purple-600" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{config.scope === 'super_admin' ? 'Global' : config.tenant_name || 'Tenant'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'host',
|
||||||
|
label: 'Server',
|
||||||
|
render: (config) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Server className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-sm">{config.host}:{config.port}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'from_email',
|
||||||
|
label: 'Sender',
|
||||||
|
render: (config) => (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">{config.from_name || 'N/A'}</span>
|
||||||
|
<span className="text-xs text-gray-500">{config.from_email || 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'is_active',
|
||||||
|
label: 'Status',
|
||||||
|
render: (config) => (
|
||||||
|
<StatusBadge variant={config.is_active ? 'success' : 'failure'}>
|
||||||
|
{config.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</StatusBadge>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
label: 'Actions',
|
||||||
|
align: 'right',
|
||||||
|
render: (config) => (
|
||||||
|
<ActionDropdown
|
||||||
|
onEdit={() => handleEdit(config)}
|
||||||
|
onDelete={() => handleDelete(config)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
currentPage="Settings"
|
||||||
|
pageHeader={{
|
||||||
|
title: 'SMTP Configurations',
|
||||||
|
description: 'Manage email delivery settings for the entire platform and individual tenants.',
|
||||||
|
action: (
|
||||||
|
<PrimaryButton onClick={() => { setSelectedConfig(null); setIsModalOpen(true); }}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add Configuration
|
||||||
|
</PrimaryButton>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="bg-white border border-gray-100 rounded-lg shadow-sm overflow-hidden">
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={configs}
|
||||||
|
isLoading={isLoading}
|
||||||
|
keyExtractor={(item) => item.id!}
|
||||||
|
emptyMessage="No SMTP configurations found"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{totalItems > limit && (
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={Math.ceil(totalItems / limit)}
|
||||||
|
totalItems={totalItems}
|
||||||
|
limit={limit}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
onLimitChange={setLimit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SmtpConfigModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
onSubmit={fetchConfigs}
|
||||||
|
config={selectedConfig}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeleteConfirmationModal
|
||||||
|
isOpen={deleteModalOpen}
|
||||||
|
onClose={() => 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}
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SmtpConfigPage;
|
||||||
237
src/pages/tenant/SmtpSettings.tsx
Normal file
237
src/pages/tenant/SmtpSettings.tsx
Normal file
@ -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<Partial<SmtpConfig>>({
|
||||||
|
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 (
|
||||||
|
<Layout currentPage="Settings" pageHeader={{ title: "SMTP Settings", description: "Loading configurations..." }}>
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-6 h-6 text-blue-600 animate-spin" />
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
currentPage="Settings"
|
||||||
|
pageHeader={{
|
||||||
|
title: "SMTP Settings",
|
||||||
|
description: "Configure your own email delivery infrastructure for notifications.",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-6 max-w-4xl">
|
||||||
|
{!hasExistingConfig && (
|
||||||
|
<div className="bg-blue-50 border border-blue-100 rounded-lg p-4 flex gap-4 items-start">
|
||||||
|
<Info className="w-5 h-5 text-blue-600 mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-blue-900">Using Platform Defaults</p>
|
||||||
|
<p className="text-sm text-blue-700 mt-1">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
|
||||||
|
<div className="p-4 md:p-6 border-b border-gray-100 flex items-center justify-between bg-gray-50/50">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-gray-900">Custom SMTP Configuration</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">Define your outgoing mail server details</p>
|
||||||
|
</div>
|
||||||
|
{hasExistingConfig && (
|
||||||
|
<StatusBadge variant={config.is_active ? 'success' : 'failure'}>
|
||||||
|
{config.is_active ? 'Configured & Active' : 'Inactive'}
|
||||||
|
</StatusBadge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 md:p-6 space-y-2">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<FormField
|
||||||
|
label="SMTP Host"
|
||||||
|
value={config.host}
|
||||||
|
onChange={(e) => setConfig({ ...config, host: e.target.value })}
|
||||||
|
placeholder="smtp.your-company.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
label="Port"
|
||||||
|
type="number"
|
||||||
|
value={config.port?.toString()}
|
||||||
|
onChange={(e) => setConfig({ ...config, port: parseInt(e.target.value) })}
|
||||||
|
placeholder="587"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6 py-4">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={config.secure}
|
||||||
|
onChange={(e) => setConfig({ ...config, secure: e.target.checked })}
|
||||||
|
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-700">Use SSL/TLS</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={config.is_active}
|
||||||
|
onChange={(e) => setConfig({ ...config, is_active: e.target.checked })}
|
||||||
|
className="w-4 h-4 text-green-600 border-gray-300 rounded focus:ring-green-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-700">Enable SMTP</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4 border-t border-gray-50">
|
||||||
|
<FormField
|
||||||
|
label="Username / Email"
|
||||||
|
value={config.username}
|
||||||
|
onChange={(e) => setConfig({ ...config, username: e.target.value })}
|
||||||
|
placeholder="notifications@yourdomain.com"
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
value={config.password}
|
||||||
|
onChange={(e) => setConfig({ ...config, password: e.target.value })}
|
||||||
|
placeholder={hasExistingConfig ? '••••••••' : 'Your SMTP password'}
|
||||||
|
helperText={hasExistingConfig ? "Leave blank to keep existing password" : ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
label="Sender Display Name"
|
||||||
|
value={config.from_name || ''}
|
||||||
|
onChange={(e) => setConfig({ ...config, from_name: e.target.value })}
|
||||||
|
placeholder="Your Company Notifications"
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="Sender Email"
|
||||||
|
type="email"
|
||||||
|
value={config.from_email || ''}
|
||||||
|
onChange={(e) => setConfig({ ...config, from_email: e.target.value })}
|
||||||
|
placeholder="noreply@yourdomain.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 md:px-6 py-4 bg-gray-50 border-t border-gray-100 flex items-center justify-between">
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={handleTestConnection}
|
||||||
|
disabled={isTesting || !config.host || !config.username}
|
||||||
|
>
|
||||||
|
{isTesting ? (
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Shield className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Test Connection
|
||||||
|
</SecondaryButton>
|
||||||
|
|
||||||
|
<PrimaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving || !config.host || !config.username}
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving..." : "Save Settings"}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SmtpSettings;
|
||||||
@ -17,6 +17,7 @@ const Notifications = lazy(() => import("@/pages/tenant/Notifications"));
|
|||||||
const AuditLogResourceTypes = lazy(() => import("@/pages/superadmin/AuditLogResourceTypes"));
|
const AuditLogResourceTypes = lazy(() => import("@/pages/superadmin/AuditLogResourceTypes"));
|
||||||
const NotificationMaster = lazy(() => import("@/pages/superadmin/NotificationMaster"));
|
const NotificationMaster = lazy(() => import("@/pages/superadmin/NotificationMaster"));
|
||||||
const NotificationTemplateMaster = lazy(() => import("@/pages/superadmin/NotificationTemplateMaster"));
|
const NotificationTemplateMaster = lazy(() => import("@/pages/superadmin/NotificationTemplateMaster"));
|
||||||
|
const SmtpConfig = lazy(() => import("@/pages/superadmin/SmtpConfig"));
|
||||||
|
|
||||||
// Loading fallback component
|
// Loading fallback component
|
||||||
const RouteLoader = (): ReactElement => (
|
const RouteLoader = (): ReactElement => (
|
||||||
@ -95,4 +96,8 @@ export const superAdminRoutes: RouteConfig[] = [
|
|||||||
path: "/notification-templates",
|
path: "/notification-templates",
|
||||||
element: <LazyRoute component={NotificationTemplateMaster} />,
|
element: <LazyRoute component={NotificationTemplateMaster} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/settings/smtp",
|
||||||
|
element: <LazyRoute component={SmtpConfig} />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -34,6 +34,7 @@ const NotificationTemplates = lazy(() => import("@/pages/tenant/NotificationTemp
|
|||||||
const FilesList = lazy(() => import("@/pages/tenant/FilesList"));
|
const FilesList = lazy(() => import("@/pages/tenant/FilesList"));
|
||||||
const FileView = lazy(() => import("@/pages/tenant/FileView"));
|
const FileView = lazy(() => import("@/pages/tenant/FileView"));
|
||||||
const StorageDashboard = lazy(() => import("@/pages/tenant/StorageDashboard"));
|
const StorageDashboard = lazy(() => import("@/pages/tenant/StorageDashboard"));
|
||||||
|
const SmtpSettings = lazy(() => import("@/pages/tenant/SmtpSettings"));
|
||||||
|
|
||||||
// Loading fallback component
|
// Loading fallback component
|
||||||
const RouteLoader = (): ReactElement => (
|
const RouteLoader = (): ReactElement => (
|
||||||
@ -156,4 +157,8 @@ export const tenantAdminRoutes: RouteConfig[] = [
|
|||||||
path: "/tenant/files/storage-dashboard",
|
path: "/tenant/files/storage-dashboard",
|
||||||
element: <LazyRoute component={StorageDashboard} />,
|
element: <LazyRoute component={StorageDashboard} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/tenant/settings/smtp",
|
||||||
|
element: <LazyRoute component={SmtpSettings} />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
57
src/services/smtp-config-service.ts
Normal file
57
src/services/smtp-config-service.ts
Normal file
@ -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<SmtpConfig>) {
|
||||||
|
const response = await apiClient.post('/smtp-config', data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async adminUpsertConfig(data: Partial<SmtpConfig>) {
|
||||||
|
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();
|
||||||
Loading…
Reference in New Issue
Block a user