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: 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" },
|
||||
|
||||
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 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: <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 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: <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