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:
@@ -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
233
frontend/src/api/mfa.ts
Normal 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;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
169
frontend/src/api/ticketEmailSettings.ts
Normal file
169
frontend/src/api/ticketEmailSettings.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user