feat: Add SMTP settings and collapsible email configuration UI
- Add SMTP fields to TicketEmailSettings model (host, port, TLS/SSL, credentials, from email/name) - Update serializers with SMTP fields and is_smtp_configured flag - Add TicketEmailTestSmtpView for testing SMTP connections - Update frontend API types and hooks for SMTP settings - Add collapsible IMAP and SMTP configuration sections with "Configured" badges - Fix TypeScript errors in mockData.ts (missing required fields, type mismatches) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
535
frontend/src/pages/EmailTemplates.tsx
Normal file
535
frontend/src/pages/EmailTemplates.tsx
Normal file
@@ -0,0 +1,535 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Mail,
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Copy,
|
||||
Eye,
|
||||
X,
|
||||
Calendar,
|
||||
Bell,
|
||||
CheckCircle,
|
||||
Megaphone,
|
||||
FileText,
|
||||
BarChart3,
|
||||
Package,
|
||||
AlertTriangle
|
||||
} from 'lucide-react';
|
||||
import api from '../api/client';
|
||||
import { EmailTemplate, EmailTemplateCategory } from '../types';
|
||||
import EmailTemplateForm from '../components/EmailTemplateForm';
|
||||
|
||||
// Category icon mapping
|
||||
const categoryIcons: Record<EmailTemplateCategory, React.ReactNode> = {
|
||||
APPOINTMENT: <Calendar className="h-4 w-4" />,
|
||||
REMINDER: <Bell className="h-4 w-4" />,
|
||||
CONFIRMATION: <CheckCircle className="h-4 w-4" />,
|
||||
MARKETING: <Megaphone className="h-4 w-4" />,
|
||||
NOTIFICATION: <FileText className="h-4 w-4" />,
|
||||
REPORT: <BarChart3 className="h-4 w-4" />,
|
||||
OTHER: <Package className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
// Category colors
|
||||
const categoryColors: Record<EmailTemplateCategory, string> = {
|
||||
APPOINTMENT: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
REMINDER: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
CONFIRMATION: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
|
||||
MARKETING: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
|
||||
NOTIFICATION: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300',
|
||||
REPORT: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300',
|
||||
OTHER: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
||||
};
|
||||
|
||||
const EmailTemplates: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<EmailTemplateCategory | 'ALL'>('ALL');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [templateToDelete, setTemplateToDelete] = useState<EmailTemplate | null>(null);
|
||||
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
||||
const [previewTemplate, setPreviewTemplate] = useState<EmailTemplate | null>(null);
|
||||
|
||||
// Fetch email templates
|
||||
const { data: templates = [], isLoading, error } = useQuery<EmailTemplate[]>({
|
||||
queryKey: ['email-templates'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/api/email-templates/');
|
||||
return data.map((t: any) => ({
|
||||
id: String(t.id),
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
subject: t.subject,
|
||||
htmlContent: t.html_content,
|
||||
textContent: t.text_content,
|
||||
scope: t.scope,
|
||||
isDefault: t.is_default,
|
||||
category: t.category,
|
||||
previewContext: t.preview_context,
|
||||
createdBy: t.created_by,
|
||||
createdByName: t.created_by_name,
|
||||
createdAt: t.created_at,
|
||||
updatedAt: t.updated_at,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// Delete template mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (templateId: string) => {
|
||||
await api.delete(`/api/email-templates/${templateId}/`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
|
||||
setShowDeleteModal(false);
|
||||
setTemplateToDelete(null);
|
||||
},
|
||||
});
|
||||
|
||||
// Duplicate template mutation
|
||||
const duplicateMutation = useMutation({
|
||||
mutationFn: async (templateId: string) => {
|
||||
const { data } = await api.post(`/api/email-templates/${templateId}/duplicate/`);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Filter templates
|
||||
const filteredTemplates = useMemo(() => {
|
||||
let result = templates;
|
||||
|
||||
// Filter by category
|
||||
if (selectedCategory !== 'ALL') {
|
||||
result = result.filter(t => t.category === selectedCategory);
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(t =>
|
||||
t.name.toLowerCase().includes(query) ||
|
||||
t.description.toLowerCase().includes(query) ||
|
||||
t.subject.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [templates, selectedCategory, searchQuery]);
|
||||
|
||||
const handleEdit = (template: EmailTemplate) => {
|
||||
setEditingTemplate(template);
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = (template: EmailTemplate) => {
|
||||
setTemplateToDelete(template);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleDuplicate = (template: EmailTemplate) => {
|
||||
duplicateMutation.mutate(template.id);
|
||||
};
|
||||
|
||||
const handlePreview = (template: EmailTemplate) => {
|
||||
setPreviewTemplate(template);
|
||||
setShowPreviewModal(true);
|
||||
};
|
||||
|
||||
const handleFormClose = () => {
|
||||
setShowCreateModal(false);
|
||||
setEditingTemplate(null);
|
||||
};
|
||||
|
||||
const handleFormSuccess = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
|
||||
handleFormClose();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<p className="text-red-800 dark:text-red-300">
|
||||
{t('common.error')}: {error instanceof Error ? error.message : 'Unknown error'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Mail className="h-7 w-7 text-brand-600" />
|
||||
{t('emailTemplates.title', 'Email Templates')}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('emailTemplates.description', 'Create and manage reusable email templates for your plugins')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
{t('emailTemplates.create', 'Create Template')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 shadow-sm">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
{/* Search Bar */}
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('emailTemplates.search', 'Search templates...')}
|
||||
className="w-full pl-10 pr-4 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-brand-500 focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-5 w-5 text-gray-400" />
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value as EmailTemplateCategory | 'ALL')}
|
||||
className="px-4 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-brand-500 focus:border-brand-500"
|
||||
>
|
||||
<option value="ALL">{t('emailTemplates.allCategories', 'All Categories')}</option>
|
||||
<option value="APPOINTMENT">{t('emailTemplates.categoryAppointment', 'Appointment')}</option>
|
||||
<option value="REMINDER">{t('emailTemplates.categoryReminder', 'Reminder')}</option>
|
||||
<option value="CONFIRMATION">{t('emailTemplates.categoryConfirmation', 'Confirmation')}</option>
|
||||
<option value="MARKETING">{t('emailTemplates.categoryMarketing', 'Marketing')}</option>
|
||||
<option value="NOTIFICATION">{t('emailTemplates.categoryNotification', 'Notification')}</option>
|
||||
<option value="REPORT">{t('emailTemplates.categoryReport', 'Report')}</option>
|
||||
<option value="OTHER">{t('emailTemplates.categoryOther', 'Other')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Filters Summary */}
|
||||
{(searchQuery || selectedCategory !== 'ALL') && (
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('emailTemplates.showing', 'Showing')} {filteredTemplates.length} {t('emailTemplates.results', 'results')}
|
||||
</span>
|
||||
{(searchQuery || selectedCategory !== 'ALL') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setSelectedCategory('ALL');
|
||||
}}
|
||||
className="text-sm text-brand-600 dark:text-brand-400 hover:underline"
|
||||
>
|
||||
{t('common.clearAll', 'Clear all')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Templates List */}
|
||||
{filteredTemplates.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<Mail className="h-12 w-12 mx-auto text-gray-400 mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-4">
|
||||
{searchQuery || selectedCategory !== 'ALL'
|
||||
? t('emailTemplates.noResults', 'No templates found matching your criteria')
|
||||
: t('emailTemplates.empty', 'No email templates yet')}
|
||||
</p>
|
||||
{!searchQuery && selectedCategory === 'ALL' && (
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('emailTemplates.createFirst', 'Create your first template')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredTemplates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow overflow-hidden"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
{/* Template Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{template.name}
|
||||
</h3>
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium ${categoryColors[template.category]}`}>
|
||||
{categoryIcons[template.category]}
|
||||
{template.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{template.description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
{template.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500 mb-3">
|
||||
<span className="font-medium">{t('emailTemplates.subject', 'Subject')}:</span> {template.subject}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium">
|
||||
{t('emailTemplates.updatedAt', 'Updated')}:
|
||||
</span>
|
||||
<span>
|
||||
{new Date(template.updatedAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{template.htmlContent && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300">
|
||||
HTML
|
||||
</span>
|
||||
)}
|
||||
{template.textContent && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
|
||||
Text
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<button
|
||||
onClick={() => handlePreview(template)}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title={t('emailTemplates.preview', 'Preview')}
|
||||
>
|
||||
<Eye className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDuplicate(template)}
|
||||
disabled={duplicateMutation.isPending}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
|
||||
title={t('emailTemplates.duplicate', 'Duplicate')}
|
||||
>
|
||||
<Copy className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(template)}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title={t('common.edit', 'Edit')}
|
||||
>
|
||||
<Edit2 className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(template)}
|
||||
className="p-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||
title={t('common.delete', 'Delete')}
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{showCreateModal && (
|
||||
<EmailTemplateForm
|
||||
template={editingTemplate}
|
||||
onClose={handleFormClose}
|
||||
onSuccess={handleFormSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteModal && templateToDelete && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full overflow-hidden">
|
||||
{/* Modal Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-red-100 dark:bg-red-900/30 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('emailTemplates.confirmDelete', 'Delete Template')}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDeleteModal(false);
|
||||
setTemplateToDelete(null);
|
||||
}}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="p-6">
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{t('emailTemplates.deleteWarning', 'Are you sure you want to delete')} <span className="font-semibold text-gray-900 dark:text-white">{templateToDelete.name}</span>?
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('emailTemplates.deleteNote', 'This action cannot be undone. Plugins using this template may no longer work correctly.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDeleteModal(false);
|
||||
setTemplateToDelete(null);
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors font-medium"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(templateToDelete.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
{t('common.deleting', 'Deleting...')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{t('common.delete', 'Delete')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview Modal */}
|
||||
{showPreviewModal && previewTemplate && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Modal Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('emailTemplates.preview', 'Preview')}: {previewTemplate.name}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowPreviewModal(false);
|
||||
setPreviewTemplate(null);
|
||||
}}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="p-6 overflow-y-auto flex-1">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('emailTemplates.subject', 'Subject')}
|
||||
</label>
|
||||
<div className="p-3 bg-gray-100 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white">
|
||||
{previewTemplate.subject}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{previewTemplate.htmlContent && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('emailTemplates.htmlPreview', 'HTML Preview')}
|
||||
</label>
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<iframe
|
||||
srcDoc={previewTemplate.htmlContent}
|
||||
className="w-full h-96 bg-white"
|
||||
title="Email Preview"
|
||||
sandbox="allow-same-origin"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewTemplate.textContent && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('emailTemplates.textPreview', 'Plain Text Preview')}
|
||||
</label>
|
||||
<pre className="p-4 bg-gray-100 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white text-sm whitespace-pre-wrap font-mono overflow-auto max-h-48">
|
||||
{previewTemplate.textContent}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowPreviewModal(false);
|
||||
setPreviewTemplate(null);
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors font-medium"
|
||||
>
|
||||
{t('common.close', 'Close')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowPreviewModal(false);
|
||||
handleEdit(previewTemplate);
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
{t('common.edit', 'Edit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailTemplates;
|
||||
@@ -30,7 +30,19 @@ const LoginPage: React.FC = () => {
|
||||
{ username, password },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
const user = data.user;
|
||||
// Check if MFA is required
|
||||
if (data.mfa_required) {
|
||||
// Store MFA challenge info in sessionStorage and redirect to MFA page
|
||||
sessionStorage.setItem('mfa_challenge', JSON.stringify({
|
||||
user_id: data.user_id,
|
||||
mfa_methods: data.mfa_methods,
|
||||
phone_last_4: data.phone_last_4,
|
||||
}));
|
||||
navigate('/mfa-verify');
|
||||
return;
|
||||
}
|
||||
|
||||
const user = data.user!;
|
||||
const currentHostname = window.location.hostname;
|
||||
const currentPort = window.location.port;
|
||||
const portStr = currentPort ? `:${currentPort}` : '';
|
||||
|
||||
620
frontend/src/pages/MFASetupPage.tsx
Normal file
620
frontend/src/pages/MFASetupPage.tsx
Normal file
@@ -0,0 +1,620 @@
|
||||
/**
|
||||
* MFA Setup Page
|
||||
* Allows users to enable/disable 2FA, manage phone verification, authenticator app, and backup codes
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
getMFAStatus,
|
||||
sendPhoneVerification,
|
||||
verifyPhone,
|
||||
enableSMSMFA,
|
||||
setupTOTP,
|
||||
verifyTOTPSetup,
|
||||
generateBackupCodes,
|
||||
disableMFA,
|
||||
listTrustedDevices,
|
||||
revokeTrustedDevice,
|
||||
revokeAllTrustedDevices,
|
||||
MFAStatus,
|
||||
TrustedDevice,
|
||||
} from '../api/mfa';
|
||||
import {
|
||||
Shield,
|
||||
Smartphone,
|
||||
Key,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Trash2,
|
||||
Monitor,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const MFASetupPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// State
|
||||
const [phoneNumber, setPhoneNumber] = useState('');
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [showBackupCodes, setShowBackupCodes] = useState(false);
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [showDisableModal, setShowDisableModal] = useState(false);
|
||||
const [disablePassword, setDisablePassword] = useState('');
|
||||
const [totpSetupData, setTotpSetupData] = useState<{
|
||||
secret: string;
|
||||
qr_code: string;
|
||||
provisioning_uri: string;
|
||||
} | null>(null);
|
||||
const [phoneSent, setPhoneSent] = useState(false);
|
||||
|
||||
// Queries
|
||||
const { data: mfaStatus, isLoading: statusLoading } = useQuery({
|
||||
queryKey: ['mfa-status'],
|
||||
queryFn: getMFAStatus,
|
||||
});
|
||||
|
||||
const { data: trustedDevicesData, isLoading: devicesLoading } = useQuery({
|
||||
queryKey: ['trusted-devices'],
|
||||
queryFn: listTrustedDevices,
|
||||
enabled: !!mfaStatus?.mfa_enabled,
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const sendPhoneCodeMutation = useMutation({
|
||||
mutationFn: sendPhoneVerification,
|
||||
onSuccess: () => {
|
||||
setPhoneSent(true);
|
||||
toast.success('Verification code sent!');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.error || 'Failed to send code');
|
||||
},
|
||||
});
|
||||
|
||||
const verifyPhoneMutation = useMutation({
|
||||
mutationFn: verifyPhone,
|
||||
onSuccess: () => {
|
||||
toast.success('Phone verified!');
|
||||
queryClient.invalidateQueries({ queryKey: ['mfa-status'] });
|
||||
setVerificationCode('');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.error || 'Invalid code');
|
||||
},
|
||||
});
|
||||
|
||||
const enableSMSMutation = useMutation({
|
||||
mutationFn: enableSMSMFA,
|
||||
onSuccess: (data) => {
|
||||
toast.success('SMS MFA enabled!');
|
||||
if (data.backup_codes) {
|
||||
setBackupCodes(data.backup_codes);
|
||||
setShowBackupCodes(true);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['mfa-status'] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.error || 'Failed to enable SMS MFA');
|
||||
},
|
||||
});
|
||||
|
||||
const setupTOTPMutation = useMutation({
|
||||
mutationFn: setupTOTP,
|
||||
onSuccess: (data) => {
|
||||
setTotpSetupData({
|
||||
secret: data.secret,
|
||||
qr_code: data.qr_code,
|
||||
provisioning_uri: data.provisioning_uri,
|
||||
});
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.error || 'Failed to setup authenticator');
|
||||
},
|
||||
});
|
||||
|
||||
const verifyTOTPMutation = useMutation({
|
||||
mutationFn: verifyTOTPSetup,
|
||||
onSuccess: (data) => {
|
||||
toast.success('Authenticator app configured!');
|
||||
if (data.backup_codes) {
|
||||
setBackupCodes(data.backup_codes);
|
||||
setShowBackupCodes(true);
|
||||
}
|
||||
setTotpSetupData(null);
|
||||
setTotpCode('');
|
||||
queryClient.invalidateQueries({ queryKey: ['mfa-status'] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.error || 'Invalid code');
|
||||
},
|
||||
});
|
||||
|
||||
const generateBackupCodesMutation = useMutation({
|
||||
mutationFn: generateBackupCodes,
|
||||
onSuccess: (data) => {
|
||||
setBackupCodes(data.backup_codes);
|
||||
setShowBackupCodes(true);
|
||||
queryClient.invalidateQueries({ queryKey: ['mfa-status'] });
|
||||
toast.success('New backup codes generated!');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.error || 'Failed to generate codes');
|
||||
},
|
||||
});
|
||||
|
||||
const disableMFAMutation = useMutation({
|
||||
mutationFn: disableMFA,
|
||||
onSuccess: () => {
|
||||
toast.success('Two-factor authentication disabled');
|
||||
setShowDisableModal(false);
|
||||
setDisablePassword('');
|
||||
queryClient.invalidateQueries({ queryKey: ['mfa-status'] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.error || 'Invalid password');
|
||||
},
|
||||
});
|
||||
|
||||
const revokeDeviceMutation = useMutation({
|
||||
mutationFn: revokeTrustedDevice,
|
||||
onSuccess: () => {
|
||||
toast.success('Device trust revoked');
|
||||
queryClient.invalidateQueries({ queryKey: ['trusted-devices'] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.error || 'Failed to revoke device');
|
||||
},
|
||||
});
|
||||
|
||||
const revokeAllDevicesMutation = useMutation({
|
||||
mutationFn: revokeAllTrustedDevices,
|
||||
onSuccess: () => {
|
||||
toast.success('All devices revoked');
|
||||
queryClient.invalidateQueries({ queryKey: ['trusted-devices'] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.error || 'Failed to revoke devices');
|
||||
},
|
||||
});
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success('Copied to clipboard!');
|
||||
};
|
||||
|
||||
if (statusLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-brand-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const trustedDevices = trustedDevicesData?.devices || [];
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-brand-100 dark:bg-brand-900/30 rounded-full">
|
||||
<Shield className="h-8 w-8 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Two-Factor Authentication
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Add an extra layer of security to your account
|
||||
</p>
|
||||
</div>
|
||||
{mfaStatus?.mfa_enabled && (
|
||||
<div className="ml-auto flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
<span className="font-medium">Enabled</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Backup Codes Modal */}
|
||||
{showBackupCodes && backupCodes.length > 0 && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl max-w-md w-full p-6 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Key className="h-6 w-6 text-amber-500" />
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Save Your Backup Codes
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Save these codes in a safe place. Each code can only be used once to access your account if you lose your phone.
|
||||
</p>
|
||||
<div className="bg-gray-100 dark:bg-gray-700 rounded-lg p-4 font-mono text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{backupCodes.map((code, index) => (
|
||||
<div key={index} className="text-gray-800 dark:text-gray-200">
|
||||
{code}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => copyToClipboard(backupCodes.join('\n'))}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowBackupCodes(false)}
|
||||
className="flex-1 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
I've Saved These
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disable MFA Modal */}
|
||||
{showDisableModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl max-w-md w-full p-6 space-y-4">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Disable Two-Factor Authentication
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Enter your password to disable 2FA. This will make your account less secure.
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
value={disablePassword}
|
||||
onChange={(e) => setDisablePassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDisableModal(false);
|
||||
setDisablePassword('');
|
||||
}}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => disableMFAMutation.mutate({ password: disablePassword })}
|
||||
disabled={!disablePassword || disableMFAMutation.isPending}
|
||||
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{disableMFAMutation.isPending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
|
||||
) : (
|
||||
'Disable 2FA'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SMS Setup */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Smartphone className="h-6 w-6 text-blue-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
SMS Authentication
|
||||
</h2>
|
||||
{mfaStatus?.phone_verified && (
|
||||
<span className="ml-auto text-sm text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
Phone verified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!mfaStatus?.phone_verified ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Verify your phone number to receive verification codes via SMS.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="tel"
|
||||
value={phoneNumber}
|
||||
onChange={(e) => setPhoneNumber(e.target.value)}
|
||||
placeholder="+1 (555) 000-0000"
|
||||
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
onClick={() => sendPhoneCodeMutation.mutate(phoneNumber)}
|
||||
disabled={!phoneNumber || sendPhoneCodeMutation.isPending}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{sendPhoneCodeMutation.isPending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
'Send Code'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{phoneSent && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
placeholder="6-digit code"
|
||||
maxLength={6}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
onClick={() => verifyPhoneMutation.mutate(verificationCode)}
|
||||
disabled={verificationCode.length !== 6 || verifyPhoneMutation.isPending}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{verifyPhoneMutation.isPending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
'Verify'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Your phone ending in {mfaStatus.phone_last_4} is verified.
|
||||
{mfaStatus.mfa_method?.includes('SMS') || mfaStatus.mfa_method === 'BOTH'
|
||||
? ' SMS authentication is enabled.'
|
||||
: ' Enable SMS to use it for verification.'}
|
||||
</p>
|
||||
{!(mfaStatus.mfa_method?.includes('SMS') || mfaStatus.mfa_method === 'BOTH') && (
|
||||
<button
|
||||
onClick={() => enableSMSMutation.mutate()}
|
||||
disabled={enableSMSMutation.isPending}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{enableSMSMutation.isPending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
'Enable SMS Authentication'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* TOTP Setup */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Shield className="h-6 w-6 text-purple-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Authenticator App
|
||||
</h2>
|
||||
{mfaStatus?.totp_verified && (
|
||||
<span className="ml-auto text-sm text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
Configured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!mfaStatus?.totp_verified ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Use an authenticator app like Google Authenticator, Authy, or 1Password to generate verification codes.
|
||||
</p>
|
||||
{!totpSetupData ? (
|
||||
<button
|
||||
onClick={() => setupTOTPMutation.mutate()}
|
||||
disabled={setupTOTPMutation.isPending}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{setupTOTPMutation.isPending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
'Set Up Authenticator App'
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col md:flex-row gap-6 items-center">
|
||||
<div className="bg-white p-4 rounded-lg">
|
||||
<img
|
||||
src={totpSetupData.qr_code}
|
||||
alt="QR Code"
|
||||
className="w-48 h-48"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
1. Scan this QR code with your authenticator app
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
2. Or manually enter this key:
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded font-mono text-sm break-all">
|
||||
{totpSetupData.secret}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(totpSetupData.secret)}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||
placeholder="Enter 6-digit code"
|
||||
maxLength={6}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
onClick={() => verifyTOTPMutation.mutate(totpCode)}
|
||||
disabled={totpCode.length !== 6 || verifyTOTPMutation.isPending}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{verifyTOTPMutation.isPending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
'Verify'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setTotpSetupData(null)}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Your authenticator app is configured and ready to use.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Backup Codes */}
|
||||
{mfaStatus?.mfa_enabled && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Key className="h-6 w-6 text-amber-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Backup Codes
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Backup codes can be used to access your account if you lose your phone.
|
||||
You have <strong>{mfaStatus.backup_codes_count}</strong> codes remaining.
|
||||
{mfaStatus.backup_codes_generated_at && (
|
||||
<span className="block mt-1 text-xs">
|
||||
Generated on {new Date(mfaStatus.backup_codes_generated_at).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => generateBackupCodesMutation.mutate()}
|
||||
disabled={generateBackupCodesMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{generateBackupCodesMutation.isPending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Generate New Codes
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trusted Devices */}
|
||||
{mfaStatus?.mfa_enabled && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Monitor className="h-6 w-6 text-gray-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Trusted Devices
|
||||
</h2>
|
||||
</div>
|
||||
{trustedDevices.length > 0 && (
|
||||
<button
|
||||
onClick={() => revokeAllDevicesMutation.mutate()}
|
||||
disabled={revokeAllDevicesMutation.isPending}
|
||||
className="text-sm text-red-600 hover:text-red-700 dark:text-red-400"
|
||||
>
|
||||
Revoke All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{devicesLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : trustedDevices.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
No trusted devices. When you log in and check "Trust this device", it will appear here.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{trustedDevices.map((device) => (
|
||||
<div
|
||||
key={device.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Monitor className="h-5 w-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{device.name || 'Unknown Device'}
|
||||
{device.is_current && (
|
||||
<span className="ml-2 text-xs text-green-600 dark:text-green-400">
|
||||
(Current)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{device.ip_address} • Last used {new Date(device.last_used_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => revokeDeviceMutation.mutate(device.id)}
|
||||
disabled={revokeDeviceMutation.isPending}
|
||||
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disable 2FA */}
|
||||
{mfaStatus?.mfa_enabled && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 rounded-xl border border-red-200 dark:border-red-800 p-6">
|
||||
<h2 className="text-lg font-semibold text-red-800 dark:text-red-200 mb-2">
|
||||
Disable Two-Factor Authentication
|
||||
</h2>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 mb-4">
|
||||
Disabling 2FA will make your account less secure. You will no longer need a verification code to log in.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowDisableModal(true)}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Disable 2FA
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MFASetupPage;
|
||||
431
frontend/src/pages/MFAVerifyPage.tsx
Normal file
431
frontend/src/pages/MFAVerifyPage.tsx
Normal file
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* MFA Verification Page
|
||||
* Shown when user has MFA enabled and needs to complete verification during login
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { sendMFALoginCode, verifyMFALogin } from '../api/mfa';
|
||||
import { setCookie } from '../utils/cookies';
|
||||
import SmoothScheduleLogo from '../components/SmoothScheduleLogo';
|
||||
import {
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Shield,
|
||||
Smartphone,
|
||||
Key,
|
||||
ArrowLeft,
|
||||
CheckCircle
|
||||
} from 'lucide-react';
|
||||
|
||||
interface MFAChallenge {
|
||||
user_id: number;
|
||||
mfa_methods: ('SMS' | 'TOTP' | 'BACKUP')[];
|
||||
phone_last_4: string | null;
|
||||
}
|
||||
|
||||
const MFAVerifyPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [challenge, setChallenge] = useState<MFAChallenge | null>(null);
|
||||
const [selectedMethod, setSelectedMethod] = useState<'SMS' | 'TOTP' | 'BACKUP' | null>(null);
|
||||
const [code, setCode] = useState(['', '', '', '', '', '']);
|
||||
const [backupCode, setBackupCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [smsSent, setSmsSent] = useState(false);
|
||||
const [trustDevice, setTrustDevice] = useState(false);
|
||||
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Get MFA challenge from sessionStorage
|
||||
const storedChallenge = sessionStorage.getItem('mfa_challenge');
|
||||
if (!storedChallenge) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(storedChallenge) as MFAChallenge;
|
||||
setChallenge(parsed);
|
||||
|
||||
// Default to TOTP if available, otherwise SMS
|
||||
if (parsed.mfa_methods.includes('TOTP')) {
|
||||
setSelectedMethod('TOTP');
|
||||
} else if (parsed.mfa_methods.includes('SMS')) {
|
||||
setSelectedMethod('SMS');
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
const handleSendSMS = async () => {
|
||||
if (!challenge) return;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await sendMFALoginCode(challenge.user_id, 'SMS');
|
||||
setSmsSent(true);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to send SMS code');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCodeChange = (index: number, value: string) => {
|
||||
// Only allow digits
|
||||
if (value && !/^\d$/.test(value)) return;
|
||||
|
||||
const newCode = [...code];
|
||||
newCode[index] = value;
|
||||
setCode(newCode);
|
||||
|
||||
// Auto-focus next input
|
||||
if (value && index < 5) {
|
||||
inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Backspace' && !code[index] && index > 0) {
|
||||
inputRefs.current[index - 1]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent) => {
|
||||
e.preventDefault();
|
||||
const pastedData = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6);
|
||||
const newCode = [...code];
|
||||
for (let i = 0; i < pastedData.length; i++) {
|
||||
newCode[i] = pastedData[i];
|
||||
}
|
||||
setCode(newCode);
|
||||
// Focus the next empty input or the last one
|
||||
const nextEmptyIndex = newCode.findIndex(c => !c);
|
||||
inputRefs.current[nextEmptyIndex === -1 ? 5 : nextEmptyIndex]?.focus();
|
||||
};
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!challenge || !selectedMethod) return;
|
||||
|
||||
const verificationCode = selectedMethod === 'BACKUP'
|
||||
? backupCode.trim()
|
||||
: code.join('');
|
||||
|
||||
if (selectedMethod !== 'BACKUP' && verificationCode.length !== 6) {
|
||||
setError('Please enter a 6-digit code');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedMethod === 'BACKUP' && !verificationCode) {
|
||||
setError('Please enter a backup code');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await verifyMFALogin(
|
||||
challenge.user_id,
|
||||
verificationCode,
|
||||
selectedMethod,
|
||||
trustDevice
|
||||
);
|
||||
|
||||
// Clear MFA challenge from storage
|
||||
sessionStorage.removeItem('mfa_challenge');
|
||||
|
||||
// Store tokens
|
||||
setCookie('access_token', response.access, 7);
|
||||
setCookie('refresh_token', response.refresh, 30);
|
||||
|
||||
// Get redirect info from user
|
||||
const user = response.user;
|
||||
const currentHostname = window.location.hostname;
|
||||
const currentPort = window.location.port;
|
||||
const portStr = currentPort ? `:${currentPort}` : '';
|
||||
|
||||
// Determine target subdomain
|
||||
const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role);
|
||||
let targetSubdomain: string | null = null;
|
||||
|
||||
if (isPlatformUser) {
|
||||
targetSubdomain = 'platform';
|
||||
} else if (user.business_subdomain) {
|
||||
targetSubdomain = user.business_subdomain;
|
||||
}
|
||||
|
||||
// Check if we need to redirect
|
||||
const isOnTargetSubdomain = currentHostname === `${targetSubdomain}.lvh.me`;
|
||||
const needsRedirect = targetSubdomain && !isOnTargetSubdomain;
|
||||
|
||||
if (needsRedirect) {
|
||||
window.location.href = `http://${targetSubdomain}.lvh.me${portStr}/?access_token=${response.access}&refresh_token=${response.refresh}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to dashboard
|
||||
navigate('/');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Invalid verification code');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getMethodIcon = (method: string) => {
|
||||
switch (method) {
|
||||
case 'SMS':
|
||||
return <Smartphone className="h-5 w-5" />;
|
||||
case 'TOTP':
|
||||
return <Shield className="h-5 w-5" />;
|
||||
case 'BACKUP':
|
||||
return <Key className="h-5 w-5" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getMethodLabel = (method: string) => {
|
||||
switch (method) {
|
||||
case 'SMS':
|
||||
return challenge?.phone_last_4
|
||||
? `SMS to ***-***-${challenge.phone_last_4}`
|
||||
: 'SMS Code';
|
||||
case 'TOTP':
|
||||
return 'Authenticator App';
|
||||
case 'BACKUP':
|
||||
return 'Backup Code';
|
||||
default:
|
||||
return method;
|
||||
}
|
||||
};
|
||||
|
||||
if (!challenge) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-brand-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-50 dark:bg-gray-900 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="p-3 bg-brand-100 dark:bg-brand-900/30 rounded-full">
|
||||
<Shield className="h-8 w-8 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Two-Factor Authentication
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Enter a verification code to complete login
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-6 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 border border-red-100 dark:border-red-800/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 dark:text-red-400 flex-shrink-0" />
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Method Selection */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
{/* Method Tabs */}
|
||||
{challenge.mfa_methods.length > 1 && (
|
||||
<div className="flex gap-2 mb-6">
|
||||
{challenge.mfa_methods.map((method) => (
|
||||
<button
|
||||
key={method}
|
||||
onClick={() => {
|
||||
setSelectedMethod(method);
|
||||
setCode(['', '', '', '', '', '']);
|
||||
setBackupCode('');
|
||||
setError('');
|
||||
}}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedMethod === method
|
||||
? 'bg-brand-100 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{getMethodIcon(method)}
|
||||
<span className="hidden sm:inline">{method === 'TOTP' ? 'App' : method === 'BACKUP' ? 'Backup' : 'SMS'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SMS Method */}
|
||||
{selectedMethod === 'SMS' && (
|
||||
<div className="space-y-4">
|
||||
{!smsSent ? (
|
||||
<>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 text-center">
|
||||
We'll send a verification code to your phone ending in{' '}
|
||||
<span className="font-medium">{challenge.phone_last_4}</span>
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSendSMS}
|
||||
disabled={loading}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-brand-600 hover:bg-brand-700 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Smartphone className="h-5 w-5" />
|
||||
Send Code
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-center gap-2 text-green-600 dark:text-green-400 mb-4">
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
<span className="text-sm">Code sent!</span>
|
||||
</div>
|
||||
<div className="flex justify-center gap-2" onPaste={handlePaste}>
|
||||
{code.map((digit, index) => (
|
||||
<input
|
||||
key={index}
|
||||
ref={(el) => { inputRefs.current[index] = el; }}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={1}
|
||||
value={digit}
|
||||
onChange={(e) => handleCodeChange(index, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(index, e)}
|
||||
className="w-12 h-14 text-center text-2xl font-semibold border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:border-brand-500 focus:ring-brand-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
autoFocus={index === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSendSMS}
|
||||
disabled={loading}
|
||||
className="text-sm text-brand-600 dark:text-brand-400 hover:underline block mx-auto mt-2"
|
||||
>
|
||||
Resend code
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TOTP Method */}
|
||||
{selectedMethod === 'TOTP' && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 text-center">
|
||||
Enter the 6-digit code from your authenticator app
|
||||
</p>
|
||||
<div className="flex justify-center gap-2" onPaste={handlePaste}>
|
||||
{code.map((digit, index) => (
|
||||
<input
|
||||
key={index}
|
||||
ref={(el) => { inputRefs.current[index] = el; }}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={1}
|
||||
value={digit}
|
||||
onChange={(e) => handleCodeChange(index, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(index, e)}
|
||||
className="w-12 h-14 text-center text-2xl font-semibold border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:border-brand-500 focus:ring-brand-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
autoFocus={index === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backup Code Method */}
|
||||
{selectedMethod === 'BACKUP' && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 text-center">
|
||||
Enter one of your backup codes
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={backupCode}
|
||||
onChange={(e) => setBackupCode(e.target.value.toUpperCase())}
|
||||
placeholder="XXXX-XXXX"
|
||||
className="w-full text-center text-lg font-mono tracking-wider border-2 border-gray-300 dark:border-gray-600 rounded-lg py-3 focus:border-brand-500 focus:ring-brand-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 text-center">
|
||||
Each backup code can only be used once
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trust Device Checkbox */}
|
||||
<div className="mt-6 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="trust-device"
|
||||
checked={trustDevice}
|
||||
onChange={(e) => setTrustDevice(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<label htmlFor="trust-device" className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Trust this device for 30 days
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Verify Button */}
|
||||
{((selectedMethod === 'SMS' && smsSent) || selectedMethod === 'TOTP' || selectedMethod === 'BACKUP') && (
|
||||
<button
|
||||
onClick={handleVerify}
|
||||
disabled={loading}
|
||||
className="w-full mt-6 flex items-center justify-center gap-2 px-4 py-3 bg-brand-600 hover:bg-brand-700 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
'Verify'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Back to Login */}
|
||||
<button
|
||||
onClick={() => {
|
||||
sessionStorage.removeItem('mfa_challenge');
|
||||
navigate('/login');
|
||||
}}
|
||||
className="mt-6 flex items-center justify-center gap-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white mx-auto"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to login
|
||||
</button>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-8 flex justify-center">
|
||||
<SmoothScheduleLogo className="h-6 w-6 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MFAVerifyPage;
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import api from '../api/client';
|
||||
import { PluginInstallation, PluginCategory } from '../types';
|
||||
import EmailTemplateSelector from '../components/EmailTemplateSelector';
|
||||
|
||||
// Category icon mapping
|
||||
const categoryIcons: Record<PluginCategory, React.ReactNode> = {
|
||||
@@ -689,7 +690,13 @@ const MyPlugins: React.FC = () => {
|
||||
{variable.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
|
||||
{variable.type === 'textarea' ? (
|
||||
{variable.type === 'email_template' ? (
|
||||
<EmailTemplateSelector
|
||||
value={configValues[key] || variable.default}
|
||||
onChange={(templateId) => setConfigValues({ ...configValues, [key]: templateId })}
|
||||
required={variable.required}
|
||||
/>
|
||||
) : variable.type === 'textarea' ? (
|
||||
<textarea
|
||||
value={configValues[key] !== undefined ? configValues[key] : (variable.default ? unescapeString(variable.default) : '')}
|
||||
onChange={(e) => setConfigValues({ ...configValues, [key]: e.target.value })}
|
||||
|
||||
@@ -25,6 +25,12 @@ import {
|
||||
Lock,
|
||||
Users,
|
||||
ExternalLink,
|
||||
Mail,
|
||||
Clock,
|
||||
Server,
|
||||
Play,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
usePlatformSettings,
|
||||
@@ -42,14 +48,23 @@ import {
|
||||
usePlatformOAuthSettings,
|
||||
useUpdatePlatformOAuthSettings,
|
||||
} from '../../hooks/usePlatformOAuth';
|
||||
import {
|
||||
useTicketEmailSettings,
|
||||
useUpdateTicketEmailSettings,
|
||||
useTestImapConnection,
|
||||
useTestSmtpConnection,
|
||||
useFetchEmailsNow,
|
||||
} from '../../hooks/useTicketEmailSettings';
|
||||
import { Send } from 'lucide-react';
|
||||
|
||||
type TabType = 'stripe' | 'tiers' | 'oauth';
|
||||
type TabType = 'general' | 'stripe' | 'tiers' | 'oauth';
|
||||
|
||||
const PlatformSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('stripe');
|
||||
const [activeTab, setActiveTab] = useState<TabType>('general');
|
||||
|
||||
const tabs: { id: TabType; label: string; icon: React.ElementType }[] = [
|
||||
{ id: 'general', label: t('platform.settings.general', 'General'), icon: Settings },
|
||||
{ id: 'stripe', label: 'Stripe', icon: CreditCard },
|
||||
{ id: 'tiers', label: t('platform.settings.tiersPricing'), icon: Layers },
|
||||
{ id: 'oauth', label: t('platform.settings.oauthProviders'), icon: Users },
|
||||
@@ -94,6 +109,7 @@ const PlatformSettings: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'general' && <GeneralSettingsTab />}
|
||||
{activeTab === 'stripe' && <StripeSettingsTab />}
|
||||
{activeTab === 'tiers' && <TiersSettingsTab />}
|
||||
{activeTab === 'oauth' && <OAuthSettingsTab />}
|
||||
@@ -101,6 +117,692 @@ const PlatformSettings: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const GeneralSettingsTab: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: emailSettings, isLoading, error } = useTicketEmailSettings();
|
||||
const updateMutation = useUpdateTicketEmailSettings();
|
||||
const testImapMutation = useTestImapConnection();
|
||||
const testSmtpMutation = useTestSmtpConnection();
|
||||
const fetchNowMutation = useFetchEmailsNow();
|
||||
|
||||
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
|
||||
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,
|
||||
});
|
||||
}
|
||||
}, [emailSettings]);
|
||||
|
||||
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 handleTestImap = async () => {
|
||||
await testImapMutation.mutateAsync();
|
||||
};
|
||||
|
||||
const handleTestSmtp = async () => {
|
||||
await testSmtpMutation.mutateAsync();
|
||||
};
|
||||
|
||||
const handleFetchNow = async () => {
|
||||
await fetchNowMutation.mutateAsync();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 text-red-700 dark:text-red-400">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<span>Failed to load email settings</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Email Processing Status */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Mail className="w-5 h-5" />
|
||||
{t('platform.settings.emailProcessing', 'Support Email Processing')}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
{emailSettings?.is_enabled ? (
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle className="w-5 h-5 text-yellow-500" />
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">Status</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{emailSettings?.is_enabled ? 'Enabled' : 'Disabled'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
{emailSettings?.is_imap_configured ? (
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle className="w-5 h-5 text-yellow-500" />
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">IMAP (Inbound)</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{emailSettings?.is_imap_configured ? 'Configured' : 'Not configured'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
{emailSettings?.is_smtp_configured ? (
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle className="w-5 h-5 text-yellow-500" />
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">SMTP (Outbound)</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{emailSettings?.is_smtp_configured ? 'Configured' : 'Not configured'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<Clock className="w-5 h-5 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">Last Check</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{emailSettings?.last_check_at
|
||||
? new Date(emailSettings.last_check_at).toLocaleString()
|
||||
: 'Never'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{emailSettings?.last_error && (
|
||||
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">
|
||||
<span className="font-medium">Last Error:</span> {emailSettings.last_error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>Emails processed: <strong>{emailSettings?.emails_processed_count || 0}</strong></span>
|
||||
<span>Check interval: <strong>{emailSettings?.check_interval_seconds || 60}s</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* IMAP Configuration */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsImapExpanded(!isImapExpanded)}
|
||||
className="w-full p-6 flex items-center justify-between text-left"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Server className="w-5 h-5" />
|
||||
{t('platform.settings.imapConfig', 'IMAP Server Configuration (Inbound)')}
|
||||
{emailSettings?.is_imap_configured && (
|
||||
<span className="ml-2 px-2 py-0.5 text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded">
|
||||
Configured
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
{isImapExpanded ? (
|
||||
<ChevronUp className="w-5 h-5 text-gray-500" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isImapExpanded && (
|
||||
<div className="px-6 pb-6 space-y-4">
|
||||
{/* Enable/Disable Toggle */}
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">Enable Email Processing</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Automatically fetch and process incoming support emails
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_enabled}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, is_enabled: e.target.checked }))}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-500 peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Server Settings */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
IMAP Host
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.imap_host}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Port
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.imap_port}
|
||||
onChange={(e) => setFormData((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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end pb-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.imap_use_ssl}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, imap_use_ssl: e.target.checked }))}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Use SSL</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.imap_username}
|
||||
onChange={(e) => setFormData((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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showImapPassword ? 'text' : 'password'}
|
||||
value={formData.imap_password}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowImapPassword(!showImapPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
{showImapPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Folder
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.imap_folder}
|
||||
onChange={(e) => setFormData((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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Support Email Domain
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.support_email_domain}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Domain for reply-to addresses (e.g., support+ticket-123@domain)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test IMAP Button */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleTestImap}
|
||||
disabled={testImapMutation.isPending || !formData.imap_host}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 font-medium rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
|
||||
>
|
||||
{testImapMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Testing IMAP...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Test IMAP Connection
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{testImapMutation.isSuccess && (
|
||||
<div className={`p-3 rounded-lg ${
|
||||
testImapMutation.data?.success
|
||||
? 'bg-green-50 dark:bg-green-900/20'
|
||||
: 'bg-red-50 dark:bg-red-900/20'
|
||||
}`}>
|
||||
<p className={`text-sm flex items-center gap-2 ${
|
||||
testImapMutation.data?.success
|
||||
? 'text-green-700 dark:text-green-400'
|
||||
: 'text-red-700 dark:text-red-400'
|
||||
}`}>
|
||||
{testImapMutation.data?.success ? (
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
)}
|
||||
{testImapMutation.data?.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SMTP Configuration */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSmtpExpanded(!isSmtpExpanded)}
|
||||
className="w-full p-6 flex items-center justify-between text-left"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Send className="w-5 h-5" />
|
||||
{t('platform.settings.smtpConfig', 'SMTP Server Configuration (Outbound)')}
|
||||
{emailSettings?.is_smtp_configured && (
|
||||
<span className="ml-2 px-2 py-0.5 text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded">
|
||||
Configured
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
{isSmtpExpanded ? (
|
||||
<ChevronUp className="w-5 h-5 text-gray-500" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isSmtpExpanded && (
|
||||
<div className="px-6 pb-6 space-y-4">
|
||||
{/* Server Settings */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
SMTP Host
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.smtp_host}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Port
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.smtp_port}
|
||||
onChange={(e) => setFormData((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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end pb-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.smtp_use_tls}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, smtp_use_tls: e.target.checked, smtp_use_ssl: e.target.checked ? false : prev.smtp_use_ssl }))}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">TLS</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-end pb-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.smtp_use_ssl}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, smtp_use_ssl: e.target.checked, smtp_use_tls: e.target.checked ? false : prev.smtp_use_tls }))}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">SSL</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.smtp_username}
|
||||
onChange={(e) => setFormData((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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSmtpPassword ? 'text' : 'password'}
|
||||
value={formData.smtp_password}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSmtpPassword(!showSmtpPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
{showSmtpPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
From Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.smtp_from_email}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
From Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.smtp_from_name}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test SMTP Button */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleTestSmtp}
|
||||
disabled={testSmtpMutation.isPending || !formData.smtp_host}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 font-medium rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
|
||||
>
|
||||
{testSmtpMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Testing SMTP...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Test SMTP Connection
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{testSmtpMutation.isSuccess && (
|
||||
<div className={`p-3 rounded-lg ${
|
||||
testSmtpMutation.data?.success
|
||||
? 'bg-green-50 dark:bg-green-900/20'
|
||||
: 'bg-red-50 dark:bg-red-900/20'
|
||||
}`}>
|
||||
<p className={`text-sm flex items-center gap-2 ${
|
||||
testSmtpMutation.data?.success
|
||||
? 'text-green-700 dark:text-green-400'
|
||||
: 'text-red-700 dark:text-red-400'
|
||||
}`}>
|
||||
{testSmtpMutation.data?.success ? (
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
)}
|
||||
{testSmtpMutation.data?.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Processing Settings */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Settings className="w-5 h-5" />
|
||||
{t('platform.settings.processingSettings', 'Processing Settings')}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-4">Email Fetching</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Check Interval (seconds)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="10"
|
||||
max="3600"
|
||||
value={formData.check_interval_seconds}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
How often to check for new emails (10-3600 seconds)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end pb-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.delete_after_processing}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, delete_after_processing: e.target.checked }))}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Delete emails after processing
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save Settings'
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleFetchNow}
|
||||
disabled={fetchNowMutation.isPending || !emailSettings?.is_imap_configured}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 font-medium rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
|
||||
>
|
||||
{fetchNowMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Fetching...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4" />
|
||||
Fetch Now
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status Messages */}
|
||||
{updateMutation.isSuccess && (
|
||||
<div className="p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<p className="text-sm text-green-700 dark:text-green-400 flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Settings saved successfully
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateMutation.isError && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">
|
||||
Failed to save settings. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fetchNowMutation.isSuccess && (
|
||||
<div className="p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<p className="text-sm text-green-700 dark:text-green-400 flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
{fetchNowMutation.data?.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StripeSettingsTab: React.FC = () => {
|
||||
const { data: settings, isLoading, error } = usePlatformSettings();
|
||||
const updateKeysMutation = useUpdateStripeKeys();
|
||||
|
||||
@@ -121,6 +121,16 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
data.permissions = inviteForm.permissions;
|
||||
}
|
||||
|
||||
// Only include limits if at least one is enabled (boolean true or numeric value set)
|
||||
const hasLimits = Object.entries(inviteForm.limits).some(([key, value]) => {
|
||||
if (typeof value === 'boolean') return value === true;
|
||||
if (typeof value === 'number') return true; // numeric limits are meaningful even if 0
|
||||
return false;
|
||||
});
|
||||
if (hasLimits) {
|
||||
data.limits = inviteForm.limits;
|
||||
}
|
||||
|
||||
if (inviteForm.personal_message.trim()) {
|
||||
data.personal_message = inviteForm.personal_message.trim();
|
||||
}
|
||||
@@ -320,24 +330,21 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature Limits (Not Yet Implemented) */}
|
||||
{/* Feature Limits & Capabilities */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Feature Limits & Capabilities
|
||||
</label>
|
||||
<span className="text-xs px-2 py-1 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 rounded-full">
|
||||
Coming Soon
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3 opacity-50">
|
||||
<div className="space-y-3">
|
||||
{/* Video Conferencing */}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.can_add_video_conferencing}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, can_add_video_conferencing: e.target.checked } })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Can add video conferencing to events
|
||||
</label>
|
||||
@@ -347,8 +354,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.max_event_types === null}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 mt-1 cursor-not-allowed"
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, max_event_types: e.target.checked ? null : 10 } })}
|
||||
className="rounded border-gray-300 dark:border-gray-600 mt-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -357,10 +364,11 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
disabled
|
||||
disabled={inviteForm.limits.max_event_types === null}
|
||||
value={inviteForm.limits.max_event_types || ''}
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, max_event_types: e.target.value ? parseInt(e.target.value) : null } })}
|
||||
placeholder="Or set a limit"
|
||||
className="mt-1 w-32 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white cursor-not-allowed"
|
||||
className="mt-1 w-32 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -370,8 +378,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.max_calendars_connected === null}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 mt-1 cursor-not-allowed"
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, max_calendars_connected: e.target.checked ? null : 5 } })}
|
||||
className="rounded border-gray-300 dark:border-gray-600 mt-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -380,10 +388,11 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
disabled
|
||||
disabled={inviteForm.limits.max_calendars_connected === null}
|
||||
value={inviteForm.limits.max_calendars_connected || ''}
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, max_calendars_connected: e.target.value ? parseInt(e.target.value) : null } })}
|
||||
placeholder="Or set a limit"
|
||||
className="mt-1 w-32 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white cursor-not-allowed"
|
||||
className="mt-1 w-32 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -393,8 +402,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.can_connect_to_api}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, can_connect_to_api: e.target.checked } })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Can connect to external APIs
|
||||
</label>
|
||||
@@ -404,8 +413,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.can_book_repeated_events}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, can_book_repeated_events: e.target.checked } })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Can book repeated/recurring events
|
||||
</label>
|
||||
@@ -415,8 +424,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.can_require_2fa}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, can_require_2fa: e.target.checked } })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Can require 2FA for users
|
||||
</label>
|
||||
@@ -426,8 +435,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.can_download_logs}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, can_download_logs: e.target.checked } })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Can download system logs
|
||||
</label>
|
||||
@@ -437,8 +446,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.can_delete_data}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, can_delete_data: e.target.checked } })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Can permanently delete data
|
||||
</label>
|
||||
@@ -448,8 +457,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.can_use_masked_phone_numbers}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, can_use_masked_phone_numbers: e.target.checked } })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Can use masked phone numbers for privacy
|
||||
</label>
|
||||
@@ -459,8 +468,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.can_use_pos}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, can_use_pos: e.target.checked } })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Can use Point of Sale (POS) system
|
||||
</label>
|
||||
@@ -470,8 +479,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.can_use_mobile_app}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, can_use_mobile_app: e.target.checked } })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Can use mobile app
|
||||
</label>
|
||||
|
||||
Reference in New Issue
Block a user