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:
poduck
2025-11-29 18:28:29 -05:00
parent 0c7d76e264
commit cfc1b36ada
94 changed files with 13419 additions and 1121 deletions

View File

@@ -13,6 +13,7 @@ import { setCookie } from './utils/cookies';
// Import Login Page
import LoginPage from './pages/LoginPage';
import MFAVerifyPage from './pages/MFAVerifyPage';
import OAuthCallback from './pages/OAuthCallback';
// Import layouts
@@ -66,6 +67,7 @@ import PlatformSupport from './pages/PlatformSupport'; // Import Platform Suppor
import PluginMarketplace from './pages/PluginMarketplace'; // Import Plugin Marketplace page
import MyPlugins from './pages/MyPlugins'; // Import My Plugins page
import Tasks from './pages/Tasks'; // Import Tasks page for scheduled plugin executions
import EmailTemplates from './pages/EmailTemplates'; // Import Email Templates page
import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications
const queryClient = new QueryClient({
@@ -127,14 +129,22 @@ const AppContent: React.FC = () => {
const { data: user, isLoading: userLoading, error: userError } = useCurrentUser();
const { data: business, isLoading: businessLoading, error: businessError } = useCurrentBusiness();
const [darkMode, setDarkMode] = useState(false);
const [darkMode, setDarkMode] = useState(() => {
// Check localStorage first, then system preference
const saved = localStorage.getItem('darkMode');
if (saved !== null) {
return JSON.parse(saved);
}
return window.matchMedia('(prefers-color-scheme: dark)').matches;
});
const updateBusinessMutation = useUpdateBusiness();
const masqueradeMutation = useMasquerade();
const logoutMutation = useLogout();
// Apply dark mode class
// Apply dark mode class and persist to localStorage
React.useEffect(() => {
document.documentElement.classList.toggle('dark', darkMode);
localStorage.setItem('darkMode', JSON.stringify(darkMode));
}, [darkMode]);
// Handle tokens in URL (from login or masquerade redirect)
@@ -222,6 +232,7 @@ const AppContent: React.FC = () => {
<Route path="/signup" element={<SignupPage />} />
</Route>
<Route path="/login" element={<LoginPage />} />
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/accept-invite" element={<AcceptInvitePage />} />
@@ -236,6 +247,7 @@ const AppContent: React.FC = () => {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/accept-invite" element={<AcceptInvitePage />} />
@@ -547,6 +559,16 @@ const AppContent: React.FC = () => {
)
}
/>
<Route
path="/email-templates"
element={
hasAccess(['owner', 'manager']) ? (
<EmailTemplates />
) : (
<Navigate to="/" />
)
}
/>
<Route path="/support" element={<PlatformSupport />} />
<Route
path="/customers"

View File

@@ -20,9 +20,10 @@ export interface MasqueradeStackEntry {
}
export interface LoginResponse {
access: string;
refresh: string;
user: {
// Regular login success
access?: string;
refresh?: string;
user?: {
id: number;
username: string;
email: string;
@@ -37,6 +38,11 @@ export interface LoginResponse {
business_subdomain?: string;
};
masquerade_stack?: MasqueradeStackEntry[];
// MFA challenge response
mfa_required?: boolean;
user_id?: number;
mfa_methods?: ('SMS' | 'TOTP' | 'BACKUP')[];
phone_last_4?: string;
}
export interface User {

233
frontend/src/api/mfa.ts Normal file
View File

@@ -0,0 +1,233 @@
/**
* MFA (Two-Factor Authentication) API
*/
import apiClient from './client';
// ============================================================================
// Types
// ============================================================================
export interface MFAStatus {
mfa_enabled: boolean;
mfa_method: 'NONE' | 'SMS' | 'TOTP' | 'BOTH';
methods: ('SMS' | 'TOTP' | 'BACKUP')[];
phone_last_4: string | null;
phone_verified: boolean;
totp_verified: boolean;
backup_codes_count: number;
backup_codes_generated_at: string | null;
trusted_devices_count: number;
}
export interface TOTPSetupResponse {
success: boolean;
secret: string;
qr_code: string; // Data URL for QR code image
provisioning_uri: string;
message: string;
}
export interface MFAEnableResponse {
success: boolean;
message: string;
mfa_method: string;
backup_codes?: string[];
backup_codes_message?: string;
}
export interface BackupCodesResponse {
success: boolean;
backup_codes: string[];
message: string;
warning: string;
}
export interface BackupCodesStatus {
count: number;
generated_at: string | null;
}
export interface TrustedDevice {
id: number;
name: string;
ip_address: string;
created_at: string;
last_used_at: string;
expires_at: string;
is_current: boolean;
}
export interface MFALoginResponse {
mfa_required: boolean;
user_id?: number;
mfa_methods?: string[];
phone_last_4?: string;
}
export interface MFAVerifyResponse {
success: boolean;
access: string;
refresh: string;
user: {
id: number;
email: string;
username: string;
first_name: string;
last_name: string;
full_name: string;
role: string;
business_subdomain: string | null;
mfa_enabled: boolean;
};
}
// ============================================================================
// MFA Status
// ============================================================================
/**
* Get current MFA status
*/
export const getMFAStatus = async (): Promise<MFAStatus> => {
const response = await apiClient.get<MFAStatus>('/api/auth/mfa/status/');
return response.data;
};
// ============================================================================
// SMS Setup
// ============================================================================
/**
* Send phone verification code
*/
export const sendPhoneVerification = async (phone: string): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post('/api/auth/mfa/phone/send/', { phone });
return response.data;
};
/**
* Verify phone number with code
*/
export const verifyPhone = async (code: string): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post('/api/auth/mfa/phone/verify/', { code });
return response.data;
};
/**
* Enable SMS MFA (requires verified phone)
*/
export const enableSMSMFA = async (): Promise<MFAEnableResponse> => {
const response = await apiClient.post<MFAEnableResponse>('/api/auth/mfa/sms/enable/');
return response.data;
};
// ============================================================================
// TOTP Setup (Authenticator App)
// ============================================================================
/**
* Initialize TOTP setup (returns QR code and secret)
*/
export const setupTOTP = async (): Promise<TOTPSetupResponse> => {
const response = await apiClient.post<TOTPSetupResponse>('/api/auth/mfa/totp/setup/');
return response.data;
};
/**
* Verify TOTP code to complete setup
*/
export const verifyTOTPSetup = async (code: string): Promise<MFAEnableResponse> => {
const response = await apiClient.post<MFAEnableResponse>('/api/auth/mfa/totp/verify/', { code });
return response.data;
};
// ============================================================================
// Backup Codes
// ============================================================================
/**
* Generate new backup codes (invalidates old ones)
*/
export const generateBackupCodes = async (): Promise<BackupCodesResponse> => {
const response = await apiClient.post<BackupCodesResponse>('/api/auth/mfa/backup-codes/');
return response.data;
};
/**
* Get backup codes status
*/
export const getBackupCodesStatus = async (): Promise<BackupCodesStatus> => {
const response = await apiClient.get<BackupCodesStatus>('/api/auth/mfa/backup-codes/status/');
return response.data;
};
// ============================================================================
// Disable MFA
// ============================================================================
/**
* Disable MFA (requires password or valid MFA code)
*/
export const disableMFA = async (credentials: { password?: string; mfa_code?: string }): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post('/api/auth/mfa/disable/', credentials);
return response.data;
};
// ============================================================================
// MFA Login Challenge
// ============================================================================
/**
* Send MFA code for login (SMS only)
*/
export const sendMFALoginCode = async (userId: number, method: 'SMS' | 'TOTP' = 'SMS'): Promise<{ success: boolean; message: string; method: string }> => {
const response = await apiClient.post('/api/auth/mfa/login/send/', { user_id: userId, method });
return response.data;
};
/**
* Verify MFA code to complete login
*/
export const verifyMFALogin = async (
userId: number,
code: string,
method: 'SMS' | 'TOTP' | 'BACKUP',
trustDevice: boolean = false
): Promise<MFAVerifyResponse> => {
const response = await apiClient.post<MFAVerifyResponse>('/api/auth/mfa/login/verify/', {
user_id: userId,
code,
method,
trust_device: trustDevice,
});
return response.data;
};
// ============================================================================
// Trusted Devices
// ============================================================================
/**
* List trusted devices
*/
export const listTrustedDevices = async (): Promise<{ devices: TrustedDevice[] }> => {
const response = await apiClient.get('/api/auth/mfa/devices/');
return response.data;
};
/**
* Revoke a specific trusted device
*/
export const revokeTrustedDevice = async (deviceId: number): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.delete(`/api/auth/mfa/devices/${deviceId}/`);
return response.data;
};
/**
* Revoke all trusted devices
*/
export const revokeAllTrustedDevices = async (): Promise<{ success: boolean; message: string; count: number }> => {
const response = await apiClient.delete('/api/auth/mfa/devices/revoke-all/');
return response.data;
};

View File

@@ -121,29 +121,34 @@ export const changePassword = async (
});
};
// 2FA API
// 2FA API (using new MFA endpoints)
export const setupTOTP = async (): Promise<TOTPSetupResponse> => {
const response = await apiClient.post('/api/auth/2fa/totp/setup/');
const response = await apiClient.post('/api/auth/mfa/totp/setup/');
return response.data;
};
export const verifyTOTP = async (code: string): Promise<TOTPVerifyResponse> => {
const response = await apiClient.post('/api/auth/2fa/totp/verify/', { code });
return response.data;
const response = await apiClient.post('/api/auth/mfa/totp/verify/', { code });
// Map response to expected format
return {
success: response.data.success,
recovery_codes: response.data.backup_codes || [],
};
};
export const disableTOTP = async (code: string): Promise<void> => {
await apiClient.post('/api/auth/2fa/totp/disable/', { code });
await apiClient.post('/api/auth/mfa/disable/', { mfa_code: code });
};
export const getRecoveryCodes = async (): Promise<string[]> => {
const response = await apiClient.get('/api/auth/2fa/recovery-codes/');
return response.data.codes;
const response = await apiClient.get('/api/auth/mfa/backup-codes/status/');
// Note: Actual codes are only shown when generated, not retrievable later
return [];
};
export const regenerateRecoveryCodes = async (): Promise<string[]> => {
const response = await apiClient.post('/api/auth/2fa/recovery-codes/regenerate/');
return response.data.codes;
const response = await apiClient.post('/api/auth/mfa/backup-codes/');
return response.data.backup_codes;
};
// Sessions API

View File

@@ -0,0 +1,169 @@
/**
* API client for ticket email settings
*/
import apiClient from './client';
export interface TicketEmailSettings {
// IMAP settings (inbound)
imap_host: string;
imap_port: number;
imap_use_ssl: boolean;
imap_username: string;
imap_password_masked: string;
imap_folder: string;
// SMTP settings (outbound)
smtp_host: string;
smtp_port: number;
smtp_use_tls: boolean;
smtp_use_ssl: boolean;
smtp_username: string;
smtp_password_masked: string;
smtp_from_email: string;
smtp_from_name: string;
// General settings
support_email_address: string;
support_email_domain: string;
is_enabled: boolean;
delete_after_processing: boolean;
check_interval_seconds: number;
max_attachment_size_mb: number;
allowed_attachment_types: string[];
// Status
last_check_at: string | null;
last_error: string;
emails_processed_count: number;
is_configured: boolean;
is_imap_configured: boolean;
is_smtp_configured: boolean;
created_at: string;
updated_at: string;
}
export interface TicketEmailSettingsUpdate {
// IMAP settings
imap_host?: string;
imap_port?: number;
imap_use_ssl?: boolean;
imap_username?: string;
imap_password?: string;
imap_folder?: string;
// SMTP settings
smtp_host?: string;
smtp_port?: number;
smtp_use_tls?: boolean;
smtp_use_ssl?: boolean;
smtp_username?: string;
smtp_password?: string;
smtp_from_email?: string;
smtp_from_name?: string;
// General settings
support_email_address?: string;
support_email_domain?: string;
is_enabled?: boolean;
delete_after_processing?: boolean;
check_interval_seconds?: number;
max_attachment_size_mb?: number;
allowed_attachment_types?: string[];
}
export interface TestConnectionResult {
success: boolean;
message: string;
}
export interface FetchNowResult {
success: boolean;
message: string;
processed: number;
}
export interface IncomingTicketEmail {
id: number;
message_id: string;
from_address: string;
from_name: string;
to_address: string;
subject: string;
body_text: string;
extracted_reply: string;
ticket: number | null;
ticket_subject: string;
matched_user: number | null;
ticket_id_from_email: string;
processing_status: 'PENDING' | 'PROCESSED' | 'FAILED' | 'SPAM' | 'NO_MATCH' | 'DUPLICATE';
processing_status_display: string;
error_message: string;
email_date: string;
received_at: string;
processed_at: string | null;
}
/**
* Get ticket email settings
*/
export const getTicketEmailSettings = async (): Promise<TicketEmailSettings> => {
const response = await apiClient.get('/api/tickets/email-settings/');
return response.data;
};
/**
* Update ticket email settings
*/
export const updateTicketEmailSettings = async (
data: TicketEmailSettingsUpdate
): Promise<TicketEmailSettings> => {
const response = await apiClient.patch('/api/tickets/email-settings/', data);
return response.data;
};
/**
* Test IMAP connection
*/
export const testImapConnection = async (): Promise<TestConnectionResult> => {
const response = await apiClient.post('/api/tickets/email-settings/test-imap/');
return response.data;
};
/**
* Test SMTP connection
*/
export const testSmtpConnection = async (): Promise<TestConnectionResult> => {
const response = await apiClient.post('/api/tickets/email-settings/test-smtp/');
return response.data;
};
// Legacy alias for backwards compatibility
export const testEmailConnection = testImapConnection;
/**
* Manually trigger email fetch
*/
export const fetchEmailsNow = async (): Promise<FetchNowResult> => {
const response = await apiClient.post('/api/tickets/email-settings/fetch-now/');
return response.data;
};
/**
* Get incoming email audit log
*/
export const getIncomingEmails = async (params?: {
status?: string;
ticket?: number;
}): Promise<IncomingTicketEmail[]> => {
const response = await apiClient.get('/api/tickets/incoming-emails/', { params });
return response.data;
};
/**
* Reprocess a failed incoming email
*/
export const reprocessIncomingEmail = async (id: number): Promise<{
success: boolean;
message: string;
comment_id?: number;
ticket_id?: number;
}> => {
const response = await apiClient.post(`/api/tickets/incoming-emails/${id}/reprocess/`);
return response.data;
};

View File

@@ -0,0 +1,456 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery } from '@tanstack/react-query';
import {
X,
Save,
Eye,
Code,
FileText,
Monitor,
Smartphone,
Plus,
AlertTriangle,
ChevronDown
} from 'lucide-react';
import api from '../api/client';
import { EmailTemplate, EmailTemplateCategory, EmailTemplateVariableGroup } from '../types';
interface EmailTemplateFormProps {
template?: EmailTemplate | null;
onClose: () => void;
onSuccess: () => void;
}
const EmailTemplateForm: React.FC<EmailTemplateFormProps> = ({
template,
onClose,
onSuccess,
}) => {
const { t } = useTranslation();
const isEditing = !!template;
// Form state
const [name, setName] = useState(template?.name || '');
const [description, setDescription] = useState(template?.description || '');
const [subject, setSubject] = useState(template?.subject || '');
const [htmlContent, setHtmlContent] = useState(template?.htmlContent || '');
const [textContent, setTextContent] = useState(template?.textContent || '');
const [category, setCategory] = useState<EmailTemplateCategory>(template?.category || 'OTHER');
// UI state
const [activeTab, setActiveTab] = useState<'html' | 'text'>('html');
const [editorMode, setEditorMode] = useState<'visual' | 'code'>('code');
const [previewDevice, setPreviewDevice] = useState<'desktop' | 'mobile'>('desktop');
const [showPreview, setShowPreview] = useState(false);
const [showVariables, setShowVariables] = useState(false);
// Fetch available variables
const { data: variablesData } = useQuery<{ variables: EmailTemplateVariableGroup[] }>({
queryKey: ['email-template-variables'],
queryFn: async () => {
const { data } = await api.get('/api/email-templates/variables/');
return data;
},
});
// Preview mutation
const previewMutation = useMutation({
mutationFn: async () => {
const { data } = await api.post('/api/email-templates/preview/', {
subject,
html_content: htmlContent,
text_content: textContent,
});
return data;
},
});
// Create/Update mutation
const saveMutation = useMutation({
mutationFn: async () => {
const payload = {
name,
description,
subject,
html_content: htmlContent,
text_content: textContent,
category,
scope: 'BUSINESS', // Business users only create business templates
};
if (isEditing && template) {
const { data } = await api.patch(`/api/email-templates/${template.id}/`, payload);
return data;
} else {
const { data } = await api.post('/api/email-templates/', payload);
return data;
}
},
onSuccess: () => {
onSuccess();
},
});
const handlePreview = () => {
previewMutation.mutate();
setShowPreview(true);
};
const insertVariable = (code: string) => {
if (activeTab === 'html') {
setHtmlContent(prev => prev + code);
} else if (activeTab === 'text') {
setTextContent(prev => prev + code);
}
};
const categories: { value: EmailTemplateCategory; label: string }[] = [
{ value: 'APPOINTMENT', label: t('emailTemplates.categoryAppointment', 'Appointment') },
{ value: 'REMINDER', label: t('emailTemplates.categoryReminder', 'Reminder') },
{ value: 'CONFIRMATION', label: t('emailTemplates.categoryConfirmation', 'Confirmation') },
{ value: 'MARKETING', label: t('emailTemplates.categoryMarketing', 'Marketing') },
{ value: 'NOTIFICATION', label: t('emailTemplates.categoryNotification', 'Notification') },
{ value: 'REPORT', label: t('emailTemplates.categoryReport', 'Report') },
{ value: 'OTHER', label: t('emailTemplates.categoryOther', 'Other') },
];
const isValid = name.trim() && subject.trim() && (htmlContent.trim() || textContent.trim());
return (
<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-6xl w-full max-h-[95vh] 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">
{isEditing
? t('emailTemplates.edit', 'Edit Template')
: t('emailTemplates.create', 'Create Template')}
</h3>
<button
onClick={onClose}
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="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column - Form */}
<div className="space-y-4">
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('emailTemplates.name', 'Template Name')} *
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('emailTemplates.namePlaceholder', 'e.g., Appointment Confirmation')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('emailTemplates.category', 'Category')}
</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value as EmailTemplateCategory)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
>
{categories.map(cat => (
<option key={cat.value} value={cat.value}>{cat.label}</option>
))}
</select>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('emailTemplates.description', 'Description')}
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('emailTemplates.descriptionPlaceholder', 'Brief description of when this template is used')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
{/* Subject */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('emailTemplates.subject', 'Subject Line')} *
</label>
<input
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder={t('emailTemplates.subjectPlaceholder', 'e.g., Your appointment is confirmed!')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
{/* Variables Dropdown */}
<div className="relative">
<button
type="button"
onClick={() => setShowVariables(!showVariables)}
className="flex items-center gap-2 text-sm text-brand-600 dark:text-brand-400 hover:underline"
>
<Plus className="h-4 w-4" />
{t('emailTemplates.insertVariable', 'Insert Variable')}
<ChevronDown className={`h-4 w-4 transition-transform ${showVariables ? 'rotate-180' : ''}`} />
</button>
{showVariables && variablesData?.variables && (
<div className="absolute z-10 mt-2 w-80 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 max-h-64 overflow-y-auto">
{variablesData.variables.map((group) => (
<div key={group.category} className="mb-4 last:mb-0">
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">
{group.category}
</h4>
<div className="space-y-1">
{group.items.map((variable) => (
<button
key={variable.code}
type="button"
onClick={() => {
insertVariable(variable.code);
setShowVariables(false);
}}
className="w-full flex items-center justify-between px-2 py-1.5 text-sm rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-left"
>
<code className="text-brand-600 dark:text-brand-400 font-mono text-xs">
{variable.code}
</code>
<span className="text-gray-500 dark:text-gray-400 text-xs ml-2">
{variable.description}
</span>
</button>
))}
</div>
</div>
))}
</div>
)}
</div>
{/* Content Tabs */}
<div>
<div className="flex items-center gap-4 mb-2">
<div className="flex rounded-lg overflow-hidden border border-gray-300 dark:border-gray-600">
<button
type="button"
onClick={() => setActiveTab('html')}
className={`px-4 py-2 text-sm font-medium flex items-center gap-2 ${
activeTab === 'html'
? 'bg-brand-600 text-white'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
}`}
>
<Code className="h-4 w-4" />
HTML
</button>
<button
type="button"
onClick={() => setActiveTab('text')}
className={`px-4 py-2 text-sm font-medium flex items-center gap-2 ${
activeTab === 'text'
? 'bg-brand-600 text-white'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
}`}
>
<FileText className="h-4 w-4" />
Text
</button>
</div>
{/* Editor Mode Toggle (for HTML only) */}
{activeTab === 'html' && (
<div className="flex rounded-lg overflow-hidden border border-gray-300 dark:border-gray-600">
<button
type="button"
onClick={() => setEditorMode('code')}
className={`px-3 py-1.5 text-xs font-medium ${
editorMode === 'code'
? 'bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white'
: 'bg-white dark:bg-gray-700 text-gray-500 dark:text-gray-400'
}`}
>
Code
</button>
<button
type="button"
onClick={() => setEditorMode('visual')}
className={`px-3 py-1.5 text-xs font-medium ${
editorMode === 'visual'
? 'bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white'
: 'bg-white dark:bg-gray-700 text-gray-500 dark:text-gray-400'
}`}
>
Visual
</button>
</div>
)}
</div>
{/* Content Editor */}
{activeTab === 'html' && (
<textarea
value={htmlContent}
onChange={(e) => setHtmlContent(e.target.value)}
rows={12}
placeholder={t('emailTemplates.htmlPlaceholder', '<html>\n <body>\n <p>Hello {{CUSTOMER_NAME}},</p>\n <p>Your appointment is confirmed!</p>\n </body>\n</html>')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 font-mono text-sm"
/>
)}
{activeTab === 'text' && (
<textarea
value={textContent}
onChange={(e) => setTextContent(e.target.value)}
rows={12}
placeholder={t('emailTemplates.textPlaceholder', 'Hello {{CUSTOMER_NAME}},\n\nYour appointment is confirmed!\n\nBest regards,\n{{BUSINESS_NAME}}')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 font-mono text-sm"
/>
)}
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{activeTab === 'html'
? t('emailTemplates.htmlHelp', 'Write HTML email content. Use variables like {{CUSTOMER_NAME}} for dynamic content.')
: t('emailTemplates.textHelp', 'Plain text fallback for email clients that don\'t support HTML.')}
</p>
</div>
</div>
{/* Right Column - Preview */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t('emailTemplates.preview', 'Preview')}
</h4>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setPreviewDevice('desktop')}
className={`p-2 rounded ${
previewDevice === 'desktop'
? 'bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
}`}
title={t('emailTemplates.desktopPreview', 'Desktop preview')}
>
<Monitor className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setPreviewDevice('mobile')}
className={`p-2 rounded ${
previewDevice === 'mobile'
? 'bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
}`}
title={t('emailTemplates.mobilePreview', 'Mobile preview')}
>
<Smartphone className="h-4 w-4" />
</button>
<button
type="button"
onClick={handlePreview}
disabled={previewMutation.isPending}
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600"
>
<Eye className="h-4 w-4" />
{t('emailTemplates.refresh', 'Refresh')}
</button>
</div>
</div>
{/* Subject Preview */}
<div className="p-3 bg-gray-100 dark:bg-gray-700 rounded-lg">
<span className="text-xs text-gray-500 dark:text-gray-400 block mb-1">
{t('emailTemplates.subject', 'Subject')}:
</span>
<span className="text-sm text-gray-900 dark:text-white font-medium">
{previewMutation.data?.subject || subject || 'No subject'}
</span>
</div>
{/* HTML Preview */}
<div
className={`border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white ${
previewDevice === 'mobile' ? 'max-w-[375px] mx-auto' : ''
}`}
>
{previewMutation.isPending ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-brand-600"></div>
</div>
) : (
<iframe
srcDoc={previewMutation.data?.html_content || htmlContent || '<p style="padding: 20px; color: #888;">No HTML content</p>'}
className="w-full h-80"
title="Email Preview"
sandbox="allow-same-origin"
/>
)}
</div>
{/* Footer Warning for Free Tier */}
{previewMutation.data?.force_footer && (
<div className="flex items-start gap-2 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-amber-800 dark:text-amber-200 font-medium">
{t('emailTemplates.footerWarning', 'Powered by SmoothSchedule footer')}
</p>
<p className="text-xs text-amber-700 dark:text-amber-300 mt-1">
{t('emailTemplates.footerWarningDesc', 'Free tier accounts include a footer in all emails. Upgrade to remove it.')}
</p>
</div>
</div>
)}
</div>
</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={onClose}
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={() => saveMutation.mutate()}
disabled={!isValid || saveMutation.isPending}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
>
{saveMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
{t('common.saving', 'Saving...')}
</>
) : (
<>
<Save className="h-4 w-4" />
{isEditing ? t('common.save', 'Save') : t('common.create', 'Create')}
</>
)}
</button>
</div>
</div>
</div>
);
};
export default EmailTemplateForm;

View File

@@ -0,0 +1,112 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Mail, ExternalLink } from 'lucide-react';
import api from '../api/client';
import { EmailTemplate } from '../types';
interface EmailTemplateSelectorProps {
value: string | number | undefined;
onChange: (templateId: string | number | undefined) => void;
category?: string;
placeholder?: string;
required?: boolean;
disabled?: boolean;
className?: string;
}
const EmailTemplateSelector: React.FC<EmailTemplateSelectorProps> = ({
value,
onChange,
category,
placeholder,
required = false,
disabled = false,
className = '',
}) => {
const { t } = useTranslation();
// Fetch email templates
const { data: templates = [], isLoading } = useQuery<EmailTemplate[]>({
queryKey: ['email-templates-list', category],
queryFn: async () => {
const params = new URLSearchParams();
if (category) params.append('category', category);
const { data } = await api.get(`/api/email-templates/?${params.toString()}`);
return data.map((t: any) => ({
id: String(t.id),
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updatedAt: t.updated_at,
}));
},
});
const selectedTemplate = templates.find(t => String(t.id) === String(value));
return (
<div className={`space-y-2 ${className}`}>
<div className="relative">
<select
value={value || ''}
onChange={(e) => onChange(e.target.value || undefined)}
disabled={disabled || isLoading}
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 disabled:opacity-50 disabled:cursor-not-allowed appearance-none"
>
<option value="">
{isLoading
? t('common.loading', 'Loading...')
: placeholder || t('emailTemplates.selectTemplate', 'Select a template...')}
</option>
{templates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}
{template.category !== 'OTHER' && ` (${template.category})`}
</option>
))}
</select>
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400 pointer-events-none" />
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
{/* Selected template info */}
{selectedTemplate && (
<div className="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-800 rounded text-sm">
<span className="text-gray-600 dark:text-gray-400 truncate">
{selectedTemplate.description || selectedTemplate.name}
</span>
<a
href={`#/email-templates`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-brand-600 dark:text-brand-400 hover:underline ml-2 flex-shrink-0"
>
<ExternalLink className="h-3 w-3" />
{t('common.edit', 'Edit')}
</a>
</div>
)}
{/* Empty state with link to create */}
{!isLoading && templates.length === 0 && (
<div className="text-sm text-gray-500 dark:text-gray-400">
{t('emailTemplates.noTemplatesYet', 'No email templates yet.')}{' '}
<a
href="#/email-templates"
className="text-brand-600 dark:text-brand-400 hover:underline"
>
{t('emailTemplates.createFirst', 'Create your first template')}
</a>
</div>
)}
</div>
);
};
export default EmailTemplateSelector;

View File

@@ -22,7 +22,8 @@ import {
Plug,
Package,
Clock,
Store
Store,
Mail
} from 'lucide-react';
import { Business, User } from '../types';
import { useLogout } from '../hooks/useAuth';
@@ -226,6 +227,14 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
<Package size={16} className="shrink-0" />
<span>{t('nav.myPlugins', 'My Plugins')}</span>
</Link>
<Link
to="/email-templates"
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/email-templates' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}
title={t('nav.emailTemplates', 'Email Templates')}
>
<Mail size={16} className="shrink-0" />
<span>{t('nav.emailTemplates', 'Email Templates')}</span>
</Link>
<Link
to="/help/plugins"
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/help/plugins' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}

View File

@@ -1,11 +1,12 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { X, User, Send, MessageSquare, Clock, AlertCircle } from 'lucide-react';
import { X, User, Send, MessageSquare, Clock, AlertCircle, Mail } from 'lucide-react';
import { Ticket, TicketComment, TicketStatus, TicketPriority, TicketCategory, TicketType } from '../types';
import { useCreateTicket, useUpdateTicket, useTicketComments, useCreateTicketComment } from '../hooks/useTickets';
import { useStaffForAssignment } from '../hooks/useUsers';
import { useStaffForAssignment, usePlatformStaffForAssignment } from '../hooks/useUsers';
import { useQueryClient } from '@tanstack/react-query';
import { useSandbox } from '../contexts/SandboxContext';
import { useCurrentUser } from '../hooks/useAuth';
interface TicketModalProps {
ticket?: Ticket | null; // If provided, it's an edit/detail view
@@ -25,6 +26,7 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose, defaultTicke
const { t } = useTranslation();
const queryClient = useQueryClient();
const { isSandbox } = useSandbox();
const { data: currentUser } = useCurrentUser();
const [subject, setSubject] = useState(ticket?.subject || '');
const [description, setDescription] = useState(ticket?.description || '');
const [priority, setPriority] = useState<TicketPriority>(ticket?.priority || 'MEDIUM');
@@ -35,11 +37,19 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose, defaultTicke
const [replyText, setReplyText] = useState('');
const [internalNoteText, setInternalNoteText] = useState('');
// Check if user is a platform admin (superuser or platform_manager)
const isPlatformAdmin = currentUser?.role && ['superuser', 'platform_manager'].includes(currentUser.role);
const isPlatformStaff = currentUser?.role && ['superuser', 'platform_manager', 'platform_support'].includes(currentUser.role);
// Check if this is a platform ticket in sandbox mode (should be disabled)
const isPlatformTicketInSandbox = ticketType === 'PLATFORM' && isSandbox;
// Fetch users for assignee dropdown
const { data: users = [] } = useStaffForAssignment();
// Fetch users for assignee dropdown - use platform staff for platform tickets
const { data: businessUsers = [] } = useStaffForAssignment();
const { data: platformUsers = [] } = usePlatformStaffForAssignment();
// Use platform staff for PLATFORM tickets, business staff otherwise
const users = ticketType === 'PLATFORM' ? platformUsers : businessUsers;
// Fetch comments for the ticket if in detail/edit mode
const { data: comments, isLoading: isLoadingComments } = useTicketComments(ticket?.id);
@@ -217,8 +227,8 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose, defaultTicke
</div>
)}
{/* Priority & Category - Hide for platform tickets when viewing/creating */}
{ticketType !== 'PLATFORM' && (
{/* Priority & Category - Show for non-PLATFORM tickets OR platform admins viewing PLATFORM tickets */}
{(ticketType !== 'PLATFORM' || isPlatformAdmin) && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="priority" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
@@ -229,7 +239,7 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose, defaultTicke
value={priority}
onChange={(e) => setPriority(e.target.value as TicketPriority)}
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!!ticket && !createTicketMutation.isPending && !updateTicketMutation.isPending}
disabled={!!ticket && !isPlatformAdmin && !createTicketMutation.isPending && !updateTicketMutation.isPending}
>
{priorityOptions.map(opt => (
<option key={opt} value={opt}>{t(`tickets.priorities.${opt.toLowerCase()}`)}</option>
@@ -245,7 +255,7 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose, defaultTicke
value={category}
onChange={(e) => setCategory(e.target.value as TicketCategory)}
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!!ticket && !createTicketMutation.isPending && !updateTicketMutation.isPending}
disabled={!!ticket && !isPlatformAdmin && !createTicketMutation.isPending && !updateTicketMutation.isPending}
>
{availableCategories.map(cat => (
<option key={cat} value={cat}>{t(`tickets.categories.${cat.toLowerCase()}`)}</option>
@@ -255,8 +265,19 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose, defaultTicke
</div>
)}
{/* Assignee & Status (only visible for existing non-PLATFORM tickets) */}
{ticket && ticketType !== 'PLATFORM' && (
{/* External Email Info - Show for platform tickets from external senders */}
{ticket && ticketType === 'PLATFORM' && isPlatformStaff && ticket.externalEmail && (
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="flex items-center gap-2 text-sm text-blue-800 dark:text-blue-200">
<Mail size={16} />
<span className="font-medium">{t('tickets.externalSender', 'External Sender')}:</span>
<span>{ticket.externalName ? `${ticket.externalName} <${ticket.externalEmail}>` : ticket.externalEmail}</span>
</div>
</div>
)}
{/* Assignee & Status - Show for existing tickets (non-PLATFORM OR platform admins viewing PLATFORM) */}
{ticket && (ticketType !== 'PLATFORM' || isPlatformAdmin) && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="assignee" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
@@ -314,7 +335,7 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose, defaultTicke
)}
</div>
)}
{ticket && ticketType !== 'PLATFORM' && ( // Show update button for existing non-PLATFORM tickets
{ticket && (ticketType !== 'PLATFORM' || isPlatformAdmin) && ( // Show update button for existing tickets (non-PLATFORM OR platform admins)
<div className="flex justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="submit"
@@ -379,8 +400,8 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose, defaultTicke
</div>
</form>
{/* Internal Note Form - Only show for non-PLATFORM tickets */}
{ticketType !== 'PLATFORM' && (
{/* Internal Note Form - Show for non-PLATFORM tickets OR platform staff viewing PLATFORM tickets */}
{(ticketType !== 'PLATFORM' || isPlatformStaff) && (
<form onSubmit={handleAddInternalNote} className="pt-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
<label className="block text-sm font-medium text-orange-600 dark:text-orange-400">
{t('tickets.internalNoteLabel', 'Internal Note')}

View File

@@ -58,7 +58,15 @@ export const SandboxProvider: React.FC<SandboxProviderProps> = ({ children }) =>
export const useSandbox = (): SandboxContextType => {
const context = useContext(SandboxContext);
if (context === undefined) {
throw new Error('useSandbox must be used within a SandboxProvider');
// Return default values when used outside SandboxProvider
// This happens for platform admins who don't have sandbox mode
return {
isSandbox: false,
sandboxEnabled: false,
isLoading: false,
toggleSandbox: async () => {},
isToggling: false,
};
}
return context;
};

View File

@@ -0,0 +1,106 @@
/**
* React Query hooks for ticket email settings
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getTicketEmailSettings,
updateTicketEmailSettings,
testImapConnection,
testSmtpConnection,
fetchEmailsNow,
getIncomingEmails,
reprocessIncomingEmail,
TicketEmailSettings,
TicketEmailSettingsUpdate,
IncomingTicketEmail,
} from '../api/ticketEmailSettings';
const QUERY_KEY = 'ticketEmailSettings';
const INCOMING_EMAILS_KEY = 'incomingTicketEmails';
/**
* Hook to fetch ticket email settings
*/
export const useTicketEmailSettings = () => {
return useQuery<TicketEmailSettings>({
queryKey: [QUERY_KEY],
queryFn: getTicketEmailSettings,
});
};
/**
* Hook to update ticket email settings
*/
export const useUpdateTicketEmailSettings = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: TicketEmailSettingsUpdate) => updateTicketEmailSettings(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
},
});
};
/**
* Hook to test IMAP connection
*/
export const useTestImapConnection = () => {
return useMutation({
mutationFn: testImapConnection,
});
};
/**
* Hook to test SMTP connection
*/
export const useTestSmtpConnection = () => {
return useMutation({
mutationFn: testSmtpConnection,
});
};
// Legacy alias
export const useTestEmailConnection = useTestImapConnection;
/**
* Hook to manually fetch emails
*/
export const useFetchEmailsNow = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: fetchEmailsNow,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
queryClient.invalidateQueries({ queryKey: [INCOMING_EMAILS_KEY] });
},
});
};
/**
* Hook to fetch incoming email audit log
*/
export const useIncomingEmails = (params?: { status?: string; ticket?: number }) => {
return useQuery<IncomingTicketEmail[]>({
queryKey: [INCOMING_EMAILS_KEY, params],
queryFn: () => getIncomingEmails(params),
});
};
/**
* Hook to reprocess a failed incoming email
*/
export const useReprocessIncomingEmail = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => reprocessIncomingEmail(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [INCOMING_EMAILS_KEY] });
},
});
};
export type { TicketEmailSettings, TicketEmailSettingsUpdate, IncomingTicketEmail };

View File

@@ -46,6 +46,29 @@ export const useStaffForAssignment = () => {
});
};
/**
* Hook to fetch platform staff members for ticket assignment.
* Returns platform admins (superuser, platform_manager, platform_support) formatted for dropdown use.
*/
export const usePlatformStaffForAssignment = () => {
return useQuery<{ id: string; name: string; email: string; role: string }[]>({
queryKey: ['platformStaffForAssignment'],
queryFn: async () => {
const response = await apiClient.get('/api/platform/users/');
// Filter to only platform-level roles and format for dropdown
const platformRoles = ['superuser', 'platform_manager', 'platform_support'];
return response.data
.filter((user: { role: string }) => platformRoles.includes(user.role))
.map((user: { id: number; name?: string; email: string; role: string }) => ({
id: String(user.id),
name: user.name || user.email,
email: user.email,
role: user.role,
}));
},
});
};
/**
* Hook to update a staff member's permissions
*/

View File

@@ -11,6 +11,7 @@ export const CURRENT_BUSINESS: Business = {
status: 'Active',
joinedAt: new Date('2023-01-15'),
resourcesCanReschedule: false,
paymentsEnabled: true,
requirePaymentMethodToBook: true,
cancellationWindowHours: 24,
lateCancellationFeePercent: 50,
@@ -89,22 +90,22 @@ const staffUserTech: User = { id: 'u_staff_tech', name: 'Jen IT', email: 'jen@te
export const RESOURCES: Resource[] = [
{ id: 'r1', name: 'Bay 1 (Lift)', type: 'ROOM' },
{ id: 'r2', name: 'Bay 2 (Lift)', type: 'ROOM' },
{ id: 'r3', name: 'Mike (Senior Mech)', type: 'STAFF', userId: staffUserAcme.id },
{ id: 'r4', name: 'Stacy Staff (Diag Tech)', type: 'STAFF', userId: STAFF_USER.id },
{ id: 'r5', name: 'Alignment Machine', type: 'EQUIPMENT' },
{ id: 'r6', name: 'Service Bay 3', type: 'ROOM', userId: RESOURCE_USER.id },
{ id: 'r1', name: 'Bay 1 (Lift)', type: 'ROOM', maxConcurrentEvents: 1 },
{ id: 'r2', name: 'Bay 2 (Lift)', type: 'ROOM', maxConcurrentEvents: 1 },
{ id: 'r3', name: 'Mike (Senior Mech)', type: 'STAFF', userId: String(staffUserAcme.id), maxConcurrentEvents: 1 },
{ id: 'r4', name: 'Stacy Staff (Diag Tech)', type: 'STAFF', userId: String(STAFF_USER.id), maxConcurrentEvents: 1 },
{ id: 'r5', name: 'Alignment Machine', type: 'EQUIPMENT', maxConcurrentEvents: 1 },
{ id: 'r6', name: 'Service Bay 3', type: 'ROOM', userId: String(RESOURCE_USER.id), maxConcurrentEvents: 1 },
];
export const SERVICES: Service[] = [
{ id: 's1', name: 'Full Synthetic Oil Change', durationMinutes: 60, price: 89.99, description: 'Premium oil and filter change.' },
{ id: 's2', name: 'Brake Pad Replacement', durationMinutes: 120, price: 245.00, description: 'Front and rear brake pad replacement.' },
{ id: 's3', name: 'Engine Diagnostics', durationMinutes: 90, price: 120.00, description: 'Full computer diagnostics of engine.' },
{ id: 's4', name: 'Tire Rotation', durationMinutes: 45, price: 40.00, description: 'Rotate and balance all four tires.' },
{ id: 's5', name: '4-Wheel Alignment', durationMinutes: 60, price: 95.50, description: 'Precision laser alignment.' },
{ id: 's6', name: 'Tire Patch', durationMinutes: 30, price: 25.00, description: 'Repair minor tire punctures.' },
{ id: 's7', name: 'Vehicle Inspection', durationMinutes: 60, price: 75.00, description: 'Comprehensive multi-point vehicle inspection.' },
{ id: 's1', name: 'Full Synthetic Oil Change', durationMinutes: 60, price: 89.99, description: 'Premium oil and filter change.', displayOrder: 1 },
{ id: 's2', name: 'Brake Pad Replacement', durationMinutes: 120, price: 245.00, description: 'Front and rear brake pad replacement.', displayOrder: 2 },
{ id: 's3', name: 'Engine Diagnostics', durationMinutes: 90, price: 120.00, description: 'Full computer diagnostics of engine.', displayOrder: 3 },
{ id: 's4', name: 'Tire Rotation', durationMinutes: 45, price: 40.00, description: 'Rotate and balance all four tires.', displayOrder: 4 },
{ id: 's5', name: '4-Wheel Alignment', durationMinutes: 60, price: 95.50, description: 'Precision laser alignment.', displayOrder: 5 },
{ id: 's6', name: 'Tire Patch', durationMinutes: 30, price: 25.00, description: 'Repair minor tire punctures.', displayOrder: 6 },
{ id: 's7', name: 'Vehicle Inspection', durationMinutes: 60, price: 75.00, description: 'Comprehensive multi-point vehicle inspection.', displayOrder: 7 },
];
const dayOffset = (days: number) => {
@@ -169,7 +170,7 @@ const customerUserCharlie: User = { id: 'u_cust_charlie', name: 'Charlie Day', e
export const CUSTOMERS: Customer[] = [
{
id: 'c1',
userId: CUSTOMER_USER.id,
userId: String(CUSTOMER_USER.id),
name: 'Alice Smith',
email: 'alice@example.com',
phone: '(555) 123-4567',
@@ -188,7 +189,7 @@ export const CUSTOMERS: Customer[] = [
},
{
id: 'c2',
userId: customerUserBob.id,
userId: String(customerUserBob.id),
name: 'Bob Jones',
email: 'bob.j@example.com',
phone: '(555) 987-6543',
@@ -203,7 +204,7 @@ export const CUSTOMERS: Customer[] = [
},
{
id: 'c3',
userId: customerUserCharlie.id,
userId: String(customerUserCharlie.id),
name: 'Charlie Day',
email: 'charlie@paddys.com',
phone: '(555) 444-3333',
@@ -296,10 +297,10 @@ export const ALL_BUSINESSES: Business[] = [
];
export const SUPPORT_TICKETS: Ticket[] = [
{ id: 't101', subject: 'Cannot connect custom domain', businessName: 'Prestige Worldwide', priority: 'High', status: 'Open', createdAt: new Date('2023-10-26T09:00:00') },
{ id: 't102', subject: 'Question about invoice #4022', businessName: 'Acme Auto Repair', priority: 'Low', status: 'In Progress', createdAt: new Date('2023-10-25T14:30:00') },
{ id: 't103', subject: 'Feature request: Group bookings', businessName: 'Tech Solutions', priority: 'Medium', status: 'Open', createdAt: new Date('2023-10-26T11:15:00') },
{ id: 't104', subject: 'Login issues for staff member', businessName: 'Mom & Pop Shop', priority: 'High', status: 'Resolved', createdAt: new Date('2023-10-24T16:45:00') },
{ id: 't101', subject: 'Cannot connect custom domain', description: 'Having issues connecting my custom domain.', ticketType: 'PLATFORM', priority: 'HIGH', status: 'OPEN', category: 'TECHNICAL', creator: 'u_owner_prestige', creatorEmail: 'brennan@prestige.com', creatorFullName: 'Brennan Huff', createdAt: '2023-10-26T09:00:00Z', updatedAt: '2023-10-26T09:00:00Z' },
{ id: 't102', subject: 'Question about invoice #4022', description: 'Need clarification on invoice charges.', ticketType: 'PLATFORM', priority: 'LOW', status: 'IN_PROGRESS', category: 'BILLING', creator: 'u1', creatorEmail: 'john@acme-auto.com', creatorFullName: 'John Owner', createdAt: '2023-10-25T14:30:00Z', updatedAt: '2023-10-25T14:30:00Z' },
{ id: 't103', subject: 'Feature request: Group bookings', description: 'Would like to request group booking feature.', ticketType: 'PLATFORM', priority: 'MEDIUM', status: 'OPEN', category: 'FEATURE_REQUEST', creator: 'u_owner_techsol', creatorEmail: 'owner@techsol.com', creatorFullName: 'Tech Solutions Owner', createdAt: '2023-10-26T11:15:00Z', updatedAt: '2023-10-26T11:15:00Z' },
{ id: 't104', subject: 'Login issues for staff member', description: 'Staff member cannot login.', ticketType: 'PLATFORM', priority: 'HIGH', status: 'RESOLVED', category: 'ACCOUNT', creator: 'u_owner_mompop', creatorEmail: 'owner@mompop.com', creatorFullName: 'Mom and Pop Owner', createdAt: '2023-10-24T16:45:00Z', updatedAt: '2023-10-24T16:45:00Z', resolvedAt: '2023-10-25T10:00:00Z' },
];
export const ALL_USERS: User[] = [

View 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;

View File

@@ -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}` : '';

View 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;

View 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;

View File

@@ -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 })}

View File

@@ -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();

View File

@@ -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>

View File

@@ -233,6 +233,9 @@ export interface Ticket {
updatedAt: string; // Date string
resolvedAt?: string; // Date string
comments?: TicketComment[]; // Nested comments
// External sender info (for tickets from non-registered users via email)
externalEmail?: string;
externalName?: string;
}
export interface TicketTemplate {
@@ -341,4 +344,52 @@ export interface PluginInstallation {
hasUpdate: boolean;
rating?: number;
review?: string;
scheduledTaskId?: string;
}
// --- Email Template Types ---
export type EmailTemplateScope = 'BUSINESS' | 'PLATFORM';
export type EmailTemplateCategory =
| 'APPOINTMENT'
| 'REMINDER'
| 'CONFIRMATION'
| 'MARKETING'
| 'NOTIFICATION'
| 'REPORT'
| 'OTHER';
export interface EmailTemplate {
id: string;
name: string;
description: string;
subject: string;
htmlContent: string;
textContent: string;
scope: EmailTemplateScope;
isDefault: boolean;
category: EmailTemplateCategory;
previewContext?: Record<string, any>;
createdBy?: number;
createdByName?: string;
createdAt: string;
updatedAt: string;
}
export interface EmailTemplatePreview {
subject: string;
htmlContent: string;
textContent: string;
forceFooter: boolean;
}
export interface EmailTemplateVariable {
code: string;
description: string;
}
export interface EmailTemplateVariableGroup {
category: string;
items: EmailTemplateVariable[];
}