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>
133
TODO_EMAIL_AND_MESSAGING.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Email Templates & Messaging TODO
|
||||
|
||||
## Completed Features
|
||||
|
||||
### Email Template System (Implemented)
|
||||
- [x] EmailTemplate model with BUSINESS and PLATFORM scopes
|
||||
- [x] Categories: Appointment, Reminder, Confirmation, Marketing, Notification, Report, Other
|
||||
- [x] Template variables: `{{BUSINESS_NAME}}`, `{{CUSTOMER_NAME}}`, `{{APPOINTMENT_DATE}}`, etc.
|
||||
- [x] Backend API (CRUD, preview, duplicate)
|
||||
- [x] Frontend Email Templates page with list/create/edit/delete
|
||||
- [x] EmailTemplateForm component with code editor and preview
|
||||
- [x] EmailTemplateSelector component for plugin configuration
|
||||
- [x] Plugin config support for `email_template` type variables
|
||||
- [x] Footer enforcement for FREE tier tenants
|
||||
- [x] Default templates seed command (`python manage.py seed_email_templates`)
|
||||
|
||||
### Default Email Templates Created
|
||||
- [x] Appointment Confirmation
|
||||
- [x] Appointment Reminder - 24 Hours
|
||||
- [x] Appointment Reminder - 1 Hour
|
||||
- [x] Appointment Rescheduled
|
||||
- [x] Appointment Cancelled
|
||||
- [x] Thank You - Appointment Complete
|
||||
- [x] Welcome New Customer
|
||||
|
||||
### Ticket Email Templates (Added)
|
||||
- [x] Ticket Assigned - Notification to staff when assigned
|
||||
- [x] Ticket Status Changed - Status update notification
|
||||
- [x] Ticket Reply - Staff Notification - When customer replies
|
||||
- [x] Ticket Reply - Customer Notification - When staff replies
|
||||
- [x] Ticket Resolved - Resolution confirmation
|
||||
|
||||
### Plugin Documentation Updated
|
||||
- [x] Documented `email_template` variable type in SCHEDULER_PLUGIN_SYSTEM.md
|
||||
- [x] Added template variable types table (text, textarea, email, number, url, email_template)
|
||||
- [x] Added ticket-related insertion codes to template_parser.py
|
||||
|
||||
---
|
||||
|
||||
## Pending Features
|
||||
|
||||
### Customer Onboarding Email Process
|
||||
- [ ] Welcome email when customer creates account
|
||||
- [ ] Email verification workflow
|
||||
- [ ] First booking celebration email
|
||||
- [ ] Loyalty program introduction email (if enabled)
|
||||
|
||||
### Internal Staff Messaging System
|
||||
- [ ] Message model (sender, recipient, subject, body, read status)
|
||||
- [ ] Conversation threading
|
||||
- [ ] Messaging UI in sidebar/header
|
||||
- [ ] Unread count badge
|
||||
- [ ] Push notifications for new messages
|
||||
- [ ] Email notification option for messages
|
||||
|
||||
### Ticket Email Notifications
|
||||
- [x] Email templates created (see "Ticket Email Templates" in Completed)
|
||||
- [x] Ticket email notification service (`tickets/email_notifications.py`)
|
||||
- TicketEmailService class with methods for each notification type
|
||||
- Renders email templates with ticket context variables
|
||||
- Falls back to default text templates if custom templates not found
|
||||
- Includes Reply-To header with ticket ID for email threading
|
||||
- [x] Django signals to trigger emails on ticket events (`tickets/signals.py`)
|
||||
- Pre-save handler to track state changes (assignee, status)
|
||||
- Post-save handlers for assignment, status change, and resolution
|
||||
- Comment handler for reply notifications
|
||||
- [x] Reply-To header with ticket ID for inbound processing
|
||||
- Format: `support+ticket-{id}@{domain}`
|
||||
- X-Ticket-ID header for easy parsing
|
||||
|
||||
### Email Reply Forwarding (Inbound Email Processing)
|
||||
- [ ] Setup inbound email handling (mail.talova.net integration)
|
||||
- [ ] Parse incoming emails to support@smoothschedule.com (or per-tenant)
|
||||
- [ ] Extract ticket ID from subject line or headers
|
||||
- [ ] Create ticket reply from email content
|
||||
- [ ] Notify assignee of new reply
|
||||
- [ ] Handle attachments
|
||||
|
||||
---
|
||||
|
||||
## Email Server Details
|
||||
|
||||
Current email server: `mail.talova.net`
|
||||
Test account: `poduck@mail.talova.net`
|
||||
|
||||
### Inbound Email Options
|
||||
|
||||
1. **Mailgun/SendGrid Inbound Routes**
|
||||
- Forward to webhook endpoint
|
||||
- Parse and process programmatically
|
||||
- Best for scalability
|
||||
|
||||
2. **IMAP Polling**
|
||||
- Connect to mail.talova.net via IMAP
|
||||
- Poll for new messages periodically
|
||||
- Process and delete/archive
|
||||
|
||||
3. **Email Piping**
|
||||
- Configure MTA to pipe emails to a Django management command
|
||||
- Real-time processing
|
||||
- Requires server access
|
||||
|
||||
---
|
||||
|
||||
## Platform vs Business Templates
|
||||
|
||||
### Platform Templates (Scope: PLATFORM)
|
||||
Used for system-wide emails:
|
||||
- Tenant invitation
|
||||
- Platform announcements
|
||||
- System notifications
|
||||
|
||||
### Business Templates (Scope: BUSINESS)
|
||||
Per-tenant customizable templates:
|
||||
- Appointment confirmations
|
||||
- Reminders
|
||||
- Customer communications
|
||||
- Marketing emails
|
||||
|
||||
---
|
||||
|
||||
## Running the Seed Command
|
||||
|
||||
```bash
|
||||
# Seed templates for all tenant schemas
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py seed_email_templates
|
||||
|
||||
# Seed for specific schema
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py seed_email_templates --schema=demo
|
||||
|
||||
# Reset to defaults (overwrites existing)
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py seed_email_templates --reset
|
||||
```
|
||||
|
Before Width: | Height: | Size: 993 B After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 840 B After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 49 KiB |
@@ -1,132 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
import os
|
||||
import time
|
||||
|
||||
# Setup Client
|
||||
client = genai.Client(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw")
|
||||
|
||||
OUTPUT_DIR = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos"
|
||||
|
||||
# Plugin configurations with detailed prompts
|
||||
plugins = [
|
||||
{
|
||||
'filename': 'daily-appointment-summary.png',
|
||||
'prompt': '''Create a modern, minimalist app icon in square format with rounded corners.
|
||||
Design: Indigo blue gradient background (#4f46e5 to lighter blue).
|
||||
Icon: Simple white envelope overlaid with a small calendar icon in the corner.
|
||||
Style: Flat design, clean geometric shapes, professional SaaS aesthetic.
|
||||
The icon should be crisp and recognizable at 48x48 pixels.'''
|
||||
},
|
||||
{
|
||||
'filename': 'no-show-tracker.png',
|
||||
'prompt': '''Create a modern, minimalist app icon in square format with rounded corners.
|
||||
Design: Red gradient background (#dc2626 to darker red).
|
||||
Icon: Simple white person silhouette with a bold white X symbol overlaid.
|
||||
Style: Flat design, clean geometric shapes, professional warning aesthetic.
|
||||
The icon should clearly convey "missed appointment" at small sizes.'''
|
||||
},
|
||||
{
|
||||
'filename': 'birthday-greetings.png',
|
||||
'prompt': '''Create a modern, minimalist app icon in square format with rounded corners.
|
||||
Design: Pink gradient background (#ec4899 to lighter pink).
|
||||
Icon: Simple white birthday cake with 3 candles or a white gift box with a bow.
|
||||
Style: Flat design, clean geometric shapes, cheerful yet professional aesthetic.
|
||||
The icon should feel celebratory and friendly at small sizes.'''
|
||||
},
|
||||
{
|
||||
'filename': 'monthly-revenue-report.png',
|
||||
'prompt': '''Create a modern, minimalist app icon in square format with rounded corners.
|
||||
Design: Green gradient background (#10b981 to darker green).
|
||||
Icon: Simple white upward trending line chart or ascending bar graph.
|
||||
Style: Flat design, clean geometric shapes, professional business analytics aesthetic.
|
||||
The icon should convey growth and success at small sizes.'''
|
||||
},
|
||||
{
|
||||
'filename': 'appointment-reminder-24hr.png',
|
||||
'prompt': '''Create a modern, minimalist app icon in square format with rounded corners.
|
||||
Design: Amber/orange gradient background (#f59e0b to darker orange).
|
||||
Icon: Simple white notification bell with a small red circular alert badge.
|
||||
Style: Flat design, clean geometric shapes, attention-grabbing yet professional aesthetic.
|
||||
The icon should clearly indicate alerts and reminders at small sizes.'''
|
||||
},
|
||||
{
|
||||
'filename': 'inactive-customer-reengagement.png',
|
||||
'prompt': '''Create a modern, minimalist app icon in square format with rounded corners.
|
||||
Design: Purple gradient background (#8b5cf6 to darker purple).
|
||||
Icon: Simple white heart symbol with a circular white refresh/return arrow around it.
|
||||
Style: Flat design, clean geometric shapes, warm and welcoming professional aesthetic.
|
||||
The icon should convey customer care and returning customers at small sizes.'''
|
||||
}
|
||||
]
|
||||
|
||||
def generate_logo(prompt, filename):
|
||||
"""Generate a logo using Gemini 2.5 Flash Image"""
|
||||
print(f"\nGenerating {filename}...")
|
||||
|
||||
try:
|
||||
response = client.models.generate_content(
|
||||
model='gemini-2.5-flash-image',
|
||||
contents=prompt,
|
||||
config=types.GenerateContentConfig(
|
||||
response_modalities=["IMAGE"]
|
||||
)
|
||||
)
|
||||
|
||||
# Save the Image
|
||||
if response.candidates[0].content.parts:
|
||||
for part in response.candidates[0].content.parts:
|
||||
if part.inline_data:
|
||||
image_data = part.inline_data.data
|
||||
image = Image.open(BytesIO(image_data))
|
||||
|
||||
# Save to output directory
|
||||
output_path = os.path.join(OUTPUT_DIR, filename)
|
||||
image.save(output_path)
|
||||
print(f"✓ Saved: {output_path}")
|
||||
return True
|
||||
else:
|
||||
print(f"✗ Model returned text instead of image: {part.text[:100]}")
|
||||
return False
|
||||
else:
|
||||
print("✗ No content parts in response")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "RESOURCE_EXHAUSTED" in error_msg or "quota" in error_msg.lower():
|
||||
print(f"✗ Quota exceeded. Please try again tomorrow when quota resets.")
|
||||
print(f" Error: {error_msg[:200]}...")
|
||||
else:
|
||||
print(f"✗ Error: {type(e).__name__}: {error_msg[:200]}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
print("=" * 70)
|
||||
print("Generating Plugin Logos with Gemini 2.5 Flash Image")
|
||||
print("=" * 70)
|
||||
|
||||
success_count = 0
|
||||
for i, plugin in enumerate(plugins):
|
||||
if i > 0:
|
||||
# Wait 3 seconds between requests to avoid rate limiting
|
||||
print("\nWaiting 3 seconds before next request...")
|
||||
time.sleep(3)
|
||||
|
||||
if generate_logo(plugin['prompt'], plugin['filename']):
|
||||
success_count += 1
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(f"Generation complete: {success_count}/{len(plugins)} successful")
|
||||
print("=" * 70)
|
||||
|
||||
if success_count == 0:
|
||||
print("\nNote: If you hit quota limits, try again tomorrow when the quota resets!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,77 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw
|
||||
except ImportError:
|
||||
print("Error: Pillow library not found.")
|
||||
print("Please install it using: pip install Pillow")
|
||||
sys.exit(1)
|
||||
|
||||
# Configuration
|
||||
OUTPUT_PATH = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos/daily-appointment-summary.png"
|
||||
SIZE = (144, 144) # Generated at 3x resolution (144px) for high quality on 48px displays
|
||||
BG_COLOR = "#4f46e5" # Indigo/Blue
|
||||
ICON_COLOR = "white"
|
||||
|
||||
def create_logo():
|
||||
# Create a new image with a transparent background
|
||||
img = Image.new('RGBA', SIZE, (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 1. Draw Background (Rounded Square)
|
||||
radius = 30
|
||||
rect_bounds = [0, 0, SIZE[0], SIZE[1]]
|
||||
draw.rounded_rectangle(rect_bounds, radius=radius, fill=BG_COLOR)
|
||||
|
||||
# 2. Draw Envelope Icon (Email concept)
|
||||
# Centered, slightly offset up to make room for calendar
|
||||
env_w = 90
|
||||
env_h = 60
|
||||
env_x = (SIZE[0] - env_w) // 2
|
||||
env_y = (SIZE[1] - env_h) // 2 - 10
|
||||
|
||||
# Envelope body
|
||||
draw.rectangle([env_x, env_y, env_x + env_w, env_y + env_h], outline=ICON_COLOR, width=5)
|
||||
|
||||
# Envelope flap (V shape)
|
||||
draw.line([env_x, env_y, env_x + env_w // 2, env_y + env_h // 2 + 5], fill=ICON_COLOR, width=5)
|
||||
draw.line([env_x + env_w, env_y, env_x + env_w // 2, env_y + env_h // 2 + 5], fill=ICON_COLOR, width=5)
|
||||
|
||||
# 3. Draw Calendar Badge (Scheduling concept)
|
||||
# Bottom right corner
|
||||
cal_size = 50
|
||||
cal_x = env_x + env_w - (cal_size // 2)
|
||||
cal_y = env_y + env_h - (cal_size // 2)
|
||||
|
||||
# Clear background behind calendar for separation
|
||||
border = 4
|
||||
draw.rounded_rectangle(
|
||||
[cal_x - border, cal_y - border, cal_x + cal_size + border, cal_y + cal_size + border],
|
||||
radius=10, fill=BG_COLOR
|
||||
)
|
||||
|
||||
# Calendar body
|
||||
draw.rounded_rectangle([cal_x, cal_y, cal_x + cal_size, cal_y + cal_size], radius=8, fill="white")
|
||||
|
||||
# Calendar red header (using a lighter indigo/blue to match theme)
|
||||
header_h = 14
|
||||
draw.rounded_rectangle(
|
||||
[cal_x, cal_y, cal_x + cal_size, cal_y + header_h],
|
||||
radius=8, corners=(True, True, False, False), fill="#818cf8"
|
||||
)
|
||||
|
||||
# Calendar 'lines'
|
||||
line_x = cal_x + 10
|
||||
line_w = cal_size - 20
|
||||
draw.line([line_x, cal_y + 22, line_x + line_w, cal_y + 22], fill=BG_COLOR, width=3)
|
||||
draw.line([line_x, cal_y + 32, line_x + line_w, cal_y + 32], fill=BG_COLOR, width=3)
|
||||
draw.line([line_x, cal_y + 42, line_x + line_w * 0.6, cal_y + 42], fill=BG_COLOR, width=3)
|
||||
|
||||
# Save the file
|
||||
os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
|
||||
img.save(OUTPUT_PATH)
|
||||
print(f"Success! Logo saved to: {OUTPUT_PATH}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_logo()
|
||||
143
frontend/public/plugin-logos/generate_icons.py
Normal file
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate plugin icons using Gemini 2.5 Flash Image API.
|
||||
Based on: https://ai.google.dev/gemini-api/docs/image-generation
|
||||
"""
|
||||
from google import genai
|
||||
from PIL import Image
|
||||
import os
|
||||
import time
|
||||
|
||||
# API Key
|
||||
client = genai.Client(api_key="AIzaSyBF3_KnttSerq2BK2CaRKSTJHN8BoXp6Hw")
|
||||
|
||||
OUTPUT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# Plugin configurations with improved prompts
|
||||
plugins = [
|
||||
{
|
||||
'filename': 'daily-appointment-summary.png',
|
||||
'prompt': '''Generate a professional app icon, 512x512 pixels, square with rounded corners.
|
||||
|
||||
Style: Modern gradient background transitioning from deep indigo (#4338ca) to vibrant blue (#3b82f6).
|
||||
|
||||
Central element: A stylized white clipboard or document with 3 horizontal lines representing a list, and a small clock or sun symbol in the top right corner indicating "daily".
|
||||
|
||||
Design principles: Minimalist, clean lines, no text, suitable for a scheduling/calendar application. The icon should be instantly recognizable as "daily summary" or "daily report" at small sizes like 32x32.'''
|
||||
},
|
||||
{
|
||||
'filename': 'no-show-tracker.png',
|
||||
'prompt': '''Generate a professional app icon, 512x512 pixels, square with rounded corners.
|
||||
|
||||
Style: Modern gradient background from deep red (#b91c1c) to coral red (#ef4444).
|
||||
|
||||
Central element: A white calendar or appointment card with a prominent empty circle or "absent" symbol - perhaps a chair silhouette with a question mark, or a clock with a slash through it indicating a missed appointment.
|
||||
|
||||
Design principles: Minimalist, clean lines, no text. The icon should clearly communicate "missed" or "no-show" at a glance. Professional warning aesthetic without being alarming.'''
|
||||
},
|
||||
{
|
||||
'filename': 'birthday-greetings.png',
|
||||
'prompt': '''Generate a professional app icon, 512x512 pixels, square with rounded corners.
|
||||
|
||||
Style: Modern gradient background from magenta (#c026d3) to pink (#ec4899).
|
||||
|
||||
Central element: A white birthday cake with 2-3 simple candles, or a wrapped gift box with a ribbon bow. Add subtle confetti dots or small stars around it for a celebratory feel.
|
||||
|
||||
Design principles: Minimalist, cheerful but professional, clean lines, no text. Should feel warm and celebratory while still fitting a business application aesthetic.'''
|
||||
},
|
||||
{
|
||||
'filename': 'monthly-revenue-report.png',
|
||||
'prompt': '''Generate a professional app icon, 512x512 pixels, square with rounded corners.
|
||||
|
||||
Style: Modern gradient background from emerald green (#059669) to teal (#14b8a6).
|
||||
|
||||
Central element: A white line chart showing an upward trend with 3-4 data points connected by lines, or ascending bar chart with dollar sign integrated subtly. The graph should clearly show growth/success.
|
||||
|
||||
Design principles: Minimalist, professional finance/analytics aesthetic, clean lines, no text. Should instantly communicate "revenue" and "growth" at small sizes.'''
|
||||
},
|
||||
{
|
||||
'filename': 'appointment-reminder-24hr.png',
|
||||
'prompt': '''Generate a professional app icon, 512x512 pixels, square with rounded corners.
|
||||
|
||||
Style: Modern gradient background from amber (#d97706) to orange (#f97316).
|
||||
|
||||
Central element: A white notification bell with a small circular badge, combined with a "24" numeral or a clock face showing time. The bell should be the primary focus with the time element secondary.
|
||||
|
||||
Design principles: Minimalist, attention-grabbing but professional, clean lines, no text except possibly "24". Should clearly indicate "reminder" and "advance notice" at small sizes.'''
|
||||
},
|
||||
{
|
||||
'filename': 'inactive-customer-reengagement.png',
|
||||
'prompt': '''Generate a professional app icon, 512x512 pixels, square with rounded corners.
|
||||
|
||||
Style: Modern gradient background from violet (#7c3aed) to purple (#a855f7).
|
||||
|
||||
Central element: A white person silhouette or user icon with a curved "return" arrow circling back to them, or a magnet symbol attracting a small person icon. Should convey "bringing customers back".
|
||||
|
||||
Design principles: Minimalist, warm and welcoming professional aesthetic, clean lines, no text. Should communicate "re-engagement" or "win back" at a glance.'''
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def generate_logo(prompt: str, filename: str) -> bool:
|
||||
"""Generate a logo using Gemini 2.5 Flash Image (per official docs)"""
|
||||
print(f"\nGenerating {filename}...")
|
||||
|
||||
try:
|
||||
# Using the exact format from Google's documentation
|
||||
response = client.models.generate_content(
|
||||
model='gemini-2.5-flash-image',
|
||||
contents=[prompt],
|
||||
)
|
||||
|
||||
# Process response per docs
|
||||
for part in response.parts:
|
||||
if part.inline_data is not None:
|
||||
# Get the raw image data and convert to PIL
|
||||
from io import BytesIO
|
||||
image_data = part.inline_data.data
|
||||
pil_image = Image.open(BytesIO(image_data))
|
||||
|
||||
# Resize to 256x256 for consistency
|
||||
if pil_image.size != (256, 256):
|
||||
pil_image = pil_image.resize((256, 256), Image.Resampling.LANCZOS)
|
||||
|
||||
output_path = os.path.join(OUTPUT_DIR, filename)
|
||||
pil_image.save(output_path, 'PNG')
|
||||
print(f" ✓ Saved: {output_path}")
|
||||
return True
|
||||
elif part.text is not None:
|
||||
print(f" Model text: {part.text[:100]}...")
|
||||
|
||||
print(" ✗ No image in response")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "RESOURCE_EXHAUSTED" in error_msg or "quota" in error_msg.lower():
|
||||
print(f" ✗ Quota exceeded - try again later")
|
||||
else:
|
||||
print(f" ✗ Error: {type(e).__name__}: {error_msg[:300]}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("Generating Plugin Icons with Gemini 2.5 Flash Image")
|
||||
print("=" * 60)
|
||||
|
||||
success_count = 0
|
||||
for i, plugin in enumerate(plugins):
|
||||
if i > 0:
|
||||
print("\nWaiting 5 seconds...")
|
||||
time.sleep(5)
|
||||
|
||||
if generate_logo(plugin['prompt'], plugin['filename']):
|
||||
success_count += 1
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"Complete: {success_count}/{len(plugins)} icons generated")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,155 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError:
|
||||
print("Error: Pillow library not found.")
|
||||
print("Please install it using: pip install Pillow")
|
||||
sys.exit(1)
|
||||
|
||||
# Configuration
|
||||
OUTPUT_DIR = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos"
|
||||
SIZE = (144, 144)
|
||||
RADIUS = 30
|
||||
|
||||
def create_base_logo(bg_color):
|
||||
"""Creates the base image with rounded corners and background color."""
|
||||
img = Image.new('RGBA', SIZE, (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
rect_bounds = [0, 0, SIZE[0], SIZE[1]]
|
||||
draw.rounded_rectangle(rect_bounds, radius=RADIUS, fill=bg_color)
|
||||
return img, draw
|
||||
|
||||
def save_logo(img, filename):
|
||||
"""Saves the image to the output directory."""
|
||||
path = os.path.join(OUTPUT_DIR, filename)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
img.save(path)
|
||||
print(f"Saved: {path}")
|
||||
|
||||
# 1. No-Show Customer Tracker (Red, Person with X)
|
||||
def create_no_show_logo():
|
||||
img, draw = create_base_logo("#dc2626") # Red
|
||||
|
||||
# Person Silhouette
|
||||
cx, cy = 72, 72
|
||||
# Head
|
||||
draw.ellipse([cx-15, 35, cx+15, 65], fill="white")
|
||||
# Shoulders/Body
|
||||
draw.rounded_rectangle([cx-30, 70, cx+30, 130], radius=10, fill="white")
|
||||
|
||||
# Cancel Symbol (Red X on white circle or just overlaid)
|
||||
# Let's use a thick Red X overlay
|
||||
x_center_y = 85
|
||||
half = 20
|
||||
width = 8
|
||||
|
||||
# Draw X
|
||||
draw.line([cx - half, x_center_y - half, cx + half, x_center_y + half], fill="#dc2626", width=width)
|
||||
draw.line([cx + half, x_center_y - half, cx - half, x_center_y + half], fill="#dc2626", width=width)
|
||||
|
||||
save_logo(img, "no-show-tracker.png")
|
||||
|
||||
# 2. Birthday Greeting Campaign (Pink, Gift)
|
||||
def create_birthday_logo():
|
||||
img, draw = create_base_logo("#ec4899") # Pink
|
||||
|
||||
# Gift Box
|
||||
box_w, box_h = 70, 60
|
||||
box_x = (SIZE[0] - box_w) // 2
|
||||
box_y = (SIZE[1] - box_h) // 2 + 10
|
||||
|
||||
# Box body
|
||||
draw.rectangle([box_x, box_y, box_x + box_w, box_y + box_h], fill="white")
|
||||
|
||||
# Ribbons (Lighter Pink)
|
||||
r_w = 12
|
||||
ribbon_color = "#fbcfe8"
|
||||
# Vertical
|
||||
draw.rectangle([box_x + (box_w - r_w)//2, box_y, box_x + (box_w + r_w)//2, box_y + box_h], fill=ribbon_color)
|
||||
# Horizontal
|
||||
draw.rectangle([box_x, box_y + (box_h - r_w)//2, box_x + box_w, box_y + (box_h + r_w)//2], fill=ribbon_color)
|
||||
|
||||
# Bow
|
||||
bow_w = 20
|
||||
draw.ellipse([72 - bow_w, box_y - 15, 72, box_y], fill="white")
|
||||
draw.ellipse([72, box_y - 15, 72 + bow_w, box_y], fill="white")
|
||||
|
||||
save_logo(img, "birthday-greetings.png")
|
||||
|
||||
# 3. Monthly Revenue Report (Green, Chart)
|
||||
def create_revenue_logo():
|
||||
img, draw = create_base_logo("#10b981") # Green
|
||||
|
||||
# Bar Chart
|
||||
margin_bottom = 110
|
||||
bar_w = 20
|
||||
gap = 10
|
||||
start_x = (144 - (3 * bar_w + 2 * gap)) // 2
|
||||
|
||||
# Bars
|
||||
heights = [30, 50, 75]
|
||||
for i, h in enumerate(heights):
|
||||
x = start_x + i * (bar_w + gap)
|
||||
draw.rectangle([x, margin_bottom - h, x + bar_w, margin_bottom], fill="white")
|
||||
|
||||
# Trend Arrow (Rising)
|
||||
arrow_start = (start_x - 5, margin_bottom - 10)
|
||||
arrow_end = (start_x + 3 * bar_w + 2 * gap + 5, margin_bottom - 90)
|
||||
|
||||
# Simple line for trend
|
||||
# draw.line([arrow_start, arrow_end], fill="white", width=4)
|
||||
|
||||
save_logo(img, "monthly-revenue-report.png")
|
||||
|
||||
# 4. Appointment Reminder (24hr) (Amber, Bell)
|
||||
def create_reminder_logo():
|
||||
img, draw = create_base_logo("#f59e0b") # Amber
|
||||
|
||||
cx, cy = 72, 70
|
||||
r = 30
|
||||
|
||||
# Bell Dome
|
||||
draw.chord([cx - r, cy - r, cx + r, cy + r], 180, 0, fill="white")
|
||||
draw.rectangle([cx - r, cy, cx + r, cy + 20], fill="white")
|
||||
|
||||
# Bell Flare
|
||||
draw.polygon([(cx - r, cy + 20), (cx + r, cy + 20), (cx + r + 5, cy + 30), (cx - r - 5, cy + 30)], fill="white")
|
||||
|
||||
# Clapper
|
||||
draw.ellipse([cx - 8, cy + 28, cx + 8, cy + 42], fill="white")
|
||||
|
||||
# Notification Dot
|
||||
draw.ellipse([cx + 15, cy - 25, cx + 35, cy - 5], fill="#dc2626")
|
||||
|
||||
save_logo(img, "appointment-reminder-24hr.png")
|
||||
|
||||
# 5. Inactive Customer Re-engagement (Purple, Heart)
|
||||
def create_inactive_logo():
|
||||
img, draw = create_base_logo("#8b5cf6") # Purple
|
||||
|
||||
hx, hy = 72, 75
|
||||
size = 18
|
||||
|
||||
# Heart Shape
|
||||
# Left circle
|
||||
draw.ellipse([hx - size * 2, hy - size, hx, hy + size], fill="white")
|
||||
# Right circle
|
||||
draw.ellipse([hx, hy - size, hx + size * 2, hy + size], fill="white")
|
||||
# Bottom triangle
|
||||
draw.polygon([(hx - size * 2 + 1, hy + 4), (hx + size * 2 - 1, hy + 4), (hx, hy + size * 2 + 8)], fill="white")
|
||||
|
||||
# Refresh/Loop Arrow (Subtle arc above)
|
||||
bbox = [25, 25, 119, 119]
|
||||
draw.arc(bbox, start=180, end=0, fill="#ddd6fe", width=5)
|
||||
draw.polygon([(119, 72), (110, 60), (128, 60)], fill="#ddd6fe")
|
||||
|
||||
save_logo(img, "inactive-customer-reengagement.png")
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_no_show_logo()
|
||||
create_birthday_logo()
|
||||
create_revenue_logo()
|
||||
create_reminder_logo()
|
||||
create_inactive_logo()
|
||||
@@ -1,39 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
|
||||
# Setup Client
|
||||
client = genai.Client(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw")
|
||||
|
||||
# Test with a simple prompt
|
||||
prompt = "Create a simple modern app icon with a blue background and white envelope symbol, square format, flat design, minimalist"
|
||||
|
||||
print(f"Testing gemini-2.0-flash-exp...")
|
||||
print(f"Prompt: '{prompt}'...")
|
||||
|
||||
try:
|
||||
response = client.models.generate_content(
|
||||
model='gemini-2.0-flash-exp',
|
||||
contents=prompt,
|
||||
config=types.GenerateContentConfig(
|
||||
response_modalities=["IMAGE"]
|
||||
)
|
||||
)
|
||||
|
||||
# Save the Image
|
||||
if response.candidates[0].content.parts:
|
||||
for part in response.candidates[0].content.parts:
|
||||
if part.inline_data:
|
||||
image_data = part.inline_data.data
|
||||
image = Image.open(BytesIO(image_data))
|
||||
image.save("test_2_0_flash.png")
|
||||
print("✓ Success! Saved test_2_0_flash.png")
|
||||
else:
|
||||
print(f"✗ Model returned text: {part.text[:100]}")
|
||||
else:
|
||||
print("✗ No content parts in response")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error: {type(e).__name__}: {str(e)[:300]}")
|
||||
@@ -1,194 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
# Gemini API configuration
|
||||
API_KEY = "AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw"
|
||||
API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent"
|
||||
|
||||
OUTPUT_DIR = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos"
|
||||
|
||||
# Plugin configurations
|
||||
plugins = [
|
||||
{
|
||||
'filename': 'daily-appointment-summary.png',
|
||||
'prompt': '''Create a modern, professional icon/logo for a plugin called "Daily Appointment Summary Email".
|
||||
|
||||
Design requirements:
|
||||
- Square icon, 512x512 pixels
|
||||
- Flat design style with a slight gradient
|
||||
- Primary color: Indigo/blue (#4f46e5)
|
||||
- Icon should combine email and calendar/scheduling concepts
|
||||
- Clean, minimalist design suitable for a SaaS application
|
||||
- White or light elements on the colored background
|
||||
- Rounded corners (similar to modern app icons)
|
||||
- Professional and trustworthy appearance
|
||||
|
||||
The icon represents a plugin that sends daily email summaries of appointments to staff members.'''
|
||||
},
|
||||
{
|
||||
'filename': 'no-show-tracker.png',
|
||||
'prompt': '''Create a modern, professional icon/logo for a plugin called "No-Show Customer Tracker".
|
||||
|
||||
Design requirements:
|
||||
- Square icon, 512x512 pixels
|
||||
- Flat design style with a slight gradient
|
||||
- Primary color: Red (#dc2626)
|
||||
- Icon should represent missed appointments or absent customers
|
||||
- Could show a person silhouette with an X, slash, or cancel symbol
|
||||
- Clean, minimalist design suitable for a SaaS application
|
||||
- White or light elements on the colored background
|
||||
- Rounded corners (similar to modern app icons)
|
||||
- Professional appearance
|
||||
|
||||
The icon represents a plugin that tracks customers who miss their appointments.'''
|
||||
},
|
||||
{
|
||||
'filename': 'birthday-greetings.png',
|
||||
'prompt': '''Create a modern, professional icon/logo for a plugin called "Birthday Greeting Campaign".
|
||||
|
||||
Design requirements:
|
||||
- Square icon, 512x512 pixels
|
||||
- Flat design style with a slight gradient
|
||||
- Primary color: Pink (#ec4899)
|
||||
- Icon should represent birthdays and celebrations
|
||||
- Could show a birthday cake, gift, party hat, or balloon
|
||||
- Clean, minimalist design suitable for a SaaS application
|
||||
- White or light elements on the colored background
|
||||
- Rounded corners (similar to modern app icons)
|
||||
- Friendly and celebratory appearance
|
||||
|
||||
The icon represents a plugin that sends birthday emails with special offers to customers.'''
|
||||
},
|
||||
{
|
||||
'filename': 'monthly-revenue-report.png',
|
||||
'prompt': '''Create a modern, professional icon/logo for a plugin called "Monthly Revenue Report".
|
||||
|
||||
Design requirements:
|
||||
- Square icon, 512x512 pixels
|
||||
- Flat design style with a slight gradient
|
||||
- Primary color: Green (#10b981)
|
||||
- Icon should represent business growth, analytics, and financial reporting
|
||||
- Could show an upward trending chart, graph, or money symbol
|
||||
- Clean, minimalist design suitable for a SaaS application
|
||||
- White or light elements on the colored background
|
||||
- Rounded corners (similar to modern app icons)
|
||||
- Professional and successful appearance
|
||||
|
||||
The icon represents a plugin that generates comprehensive monthly business statistics and revenue reports.'''
|
||||
},
|
||||
{
|
||||
'filename': 'appointment-reminder-24hr.png',
|
||||
'prompt': '''Create a modern, professional icon/logo for a plugin called "Appointment Reminder (24hr)".
|
||||
|
||||
Design requirements:
|
||||
- Square icon, 512x512 pixels
|
||||
- Flat design style with a slight gradient
|
||||
- Primary color: Amber/Orange (#f59e0b)
|
||||
- Icon should represent notifications, alerts, and reminders
|
||||
- Could show a bell with a notification badge, clock, or alarm
|
||||
- Clean, minimalist design suitable for a SaaS application
|
||||
- White or light elements on the colored background
|
||||
- Rounded corners (similar to modern app icons)
|
||||
- Attention-grabbing but professional appearance
|
||||
|
||||
The icon represents a plugin that sends reminder emails to customers 24 hours before their appointments.'''
|
||||
},
|
||||
{
|
||||
'filename': 'inactive-customer-reengagement.png',
|
||||
'prompt': '''Create a modern, professional icon/logo for a plugin called "Inactive Customer Re-engagement".
|
||||
|
||||
Design requirements:
|
||||
- Square icon, 512x512 pixels
|
||||
- Flat design style with a slight gradient
|
||||
- Primary color: Purple (#8b5cf6)
|
||||
- Icon should represent customer retention, returning customers, or re-engagement
|
||||
- Could show a heart, person with return arrow, refresh symbol, or comeback concept
|
||||
- Clean, minimalist design suitable for a SaaS application
|
||||
- White or light elements on the colored background
|
||||
- Rounded corners (similar to modern app icons)
|
||||
- Warm and welcoming appearance
|
||||
|
||||
The icon represents a plugin that wins back customers who haven't booked appointments recently.'''
|
||||
}
|
||||
]
|
||||
|
||||
def generate_image(prompt, filename):
|
||||
"""Generate an image using Gemini API"""
|
||||
print(f"\nGenerating {filename}...")
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"contents": [{
|
||||
"parts": [{
|
||||
"text": prompt
|
||||
}]
|
||||
}],
|
||||
"generationConfig": {
|
||||
"temperature": 1,
|
||||
"topK": 40,
|
||||
"topP": 0.95,
|
||||
"maxOutputTokens": 8192,
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{API_URL}?key={API_KEY}",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=120
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
|
||||
# Check if there's an image in the response
|
||||
if 'candidates' in result and len(result['candidates']) > 0:
|
||||
candidate = result['candidates'][0]
|
||||
if 'content' in candidate and 'parts' in candidate['content']:
|
||||
for part in candidate['content']['parts']:
|
||||
if 'inlineData' in part:
|
||||
# Extract and save the image
|
||||
image_data = base64.b64decode(part['inlineData']['data'])
|
||||
output_path = os.path.join(OUTPUT_DIR, filename)
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(image_data)
|
||||
print(f"✓ Saved: {output_path}")
|
||||
return True
|
||||
elif 'text' in part:
|
||||
print(f"Response: {part['text'][:200]}...")
|
||||
|
||||
print(f"✗ No image generated. Response: {result}")
|
||||
return False
|
||||
else:
|
||||
print(f"✗ API Error ({response.status_code}): {response.text}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error: {str(e)}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
# Create output directory
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
print("Starting logo generation with Gemini API...")
|
||||
print("=" * 60)
|
||||
|
||||
success_count = 0
|
||||
for plugin in plugins:
|
||||
if generate_image(plugin['prompt'], plugin['filename']):
|
||||
success_count += 1
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"Generation complete: {success_count}/{len(plugins)} successful")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,112 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import google.generativeai as genai
|
||||
import os
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
# Configure API Key
|
||||
genai.configure(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw")
|
||||
|
||||
OUTPUT_DIR = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos"
|
||||
|
||||
# Plugin configurations
|
||||
plugins = [
|
||||
{
|
||||
'filename': 'daily-appointment-summary.png',
|
||||
'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners.
|
||||
Design: Indigo blue gradient background (#4f46e5). White simple envelope icon combined with a small calendar symbol.
|
||||
Style: Flat design, clean geometric shapes, professional SaaS application aesthetic.
|
||||
The icon should be instantly recognizable at 48x48 pixels.'''
|
||||
},
|
||||
{
|
||||
'filename': 'no-show-tracker.png',
|
||||
'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners.
|
||||
Design: Red gradient background (#dc2626). White simple person silhouette with a bold X or cancel symbol overlay.
|
||||
Style: Flat design, clean geometric shapes, professional SaaS application aesthetic.
|
||||
The icon should clearly convey "missed appointment" at small sizes.'''
|
||||
},
|
||||
{
|
||||
'filename': 'birthday-greetings.png',
|
||||
'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners.
|
||||
Design: Pink gradient background (#ec4899). White simple birthday cake with candles or gift box with bow.
|
||||
Style: Flat design, clean geometric shapes, cheerful yet professional aesthetic.
|
||||
The icon should feel celebratory and friendly at small sizes.'''
|
||||
},
|
||||
{
|
||||
'filename': 'monthly-revenue-report.png',
|
||||
'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners.
|
||||
Design: Green gradient background (#10b981). White simple upward trending line chart or bar graph showing growth.
|
||||
Style: Flat design, clean geometric shapes, professional business analytics aesthetic.
|
||||
The icon should convey success and growth at small sizes.'''
|
||||
},
|
||||
{
|
||||
'filename': 'appointment-reminder-24hr.png',
|
||||
'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners.
|
||||
Design: Amber/orange gradient background (#f59e0b). White simple notification bell with a small red alert dot or badge.
|
||||
Style: Flat design, clean geometric shapes, attention-grabbing yet professional aesthetic.
|
||||
The icon should clearly indicate alerts and reminders at small sizes.'''
|
||||
},
|
||||
{
|
||||
'filename': 'inactive-customer-reengagement.png',
|
||||
'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners.
|
||||
Design: Purple gradient background (#8b5cf6). White simple heart symbol with a circular refresh/return arrow around it.
|
||||
Style: Flat design, clean geometric shapes, warm and welcoming professional aesthetic.
|
||||
The icon should convey customer care and comeback at small sizes.'''
|
||||
}
|
||||
]
|
||||
|
||||
def generate_image(prompt, filename):
|
||||
"""Generate an image using Gemini 2.0 Flash Image Generation"""
|
||||
print(f"\nGenerating {filename}...")
|
||||
|
||||
try:
|
||||
# Use the image generation model
|
||||
model = genai.GenerativeModel('gemini-2.0-flash-exp-image-generation')
|
||||
|
||||
# Generate the image
|
||||
response = model.generate_content(prompt)
|
||||
|
||||
# Check for generated image
|
||||
if hasattr(response, 'parts') and response.parts:
|
||||
for part in response.parts:
|
||||
if hasattr(part, 'inline_data') and part.inline_data:
|
||||
# Extract image data
|
||||
image_data = part.inline_data.data
|
||||
|
||||
# Save the image
|
||||
output_path = os.path.join(OUTPUT_DIR, filename)
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(image_data)
|
||||
|
||||
print(f"✓ Saved: {output_path} ({len(image_data)} bytes)")
|
||||
return True
|
||||
|
||||
# If no image data found, check if there's text response
|
||||
if hasattr(response, 'text'):
|
||||
print(f"✗ No image generated. Response text: {response.text[:200]}")
|
||||
else:
|
||||
print(f"✗ No image generated. Response: {response}")
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error: {type(e).__name__}: {str(e)}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
# Create output directory
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
print("Starting logo generation with Gemini 2.0 Flash Image Generation...")
|
||||
print("=" * 60)
|
||||
|
||||
success_count = 0
|
||||
for plugin in plugins:
|
||||
if generate_image(plugin['prompt'], plugin['filename']):
|
||||
success_count += 1
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"Generation complete: {success_count}/{len(plugins)} successful")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,146 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
import base64
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# Imagen API configuration
|
||||
API_KEY = "AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw"
|
||||
MODEL = "imagen-4.0-generate-preview-06-06"
|
||||
API_URL = f"https://generativelanguage.googleapis.com/v1beta/models/{MODEL}:predict"
|
||||
|
||||
OUTPUT_DIR = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos"
|
||||
|
||||
# Plugin configurations
|
||||
plugins = [
|
||||
{
|
||||
'filename': 'daily-appointment-summary.png',
|
||||
'prompt': '''A modern, minimalist app icon for "Daily Appointment Summary Email" plugin.
|
||||
Square format with rounded corners. Indigo blue gradient background (#4f46e5).
|
||||
White icon showing a combination of an envelope and a calendar.
|
||||
Flat design, clean lines, professional SaaS application style.
|
||||
The icon should be simple and recognizable at small sizes.'''
|
||||
},
|
||||
{
|
||||
'filename': 'no-show-tracker.png',
|
||||
'prompt': '''A modern, minimalist app icon for "No-Show Customer Tracker" plugin.
|
||||
Square format with rounded corners. Red gradient background (#dc2626).
|
||||
White icon showing a person silhouette with an X or cancel symbol overlay.
|
||||
Flat design, clean lines, professional SaaS application style.
|
||||
The icon should convey missed appointments clearly at small sizes.'''
|
||||
},
|
||||
{
|
||||
'filename': 'birthday-greetings.png',
|
||||
'prompt': '''A modern, minimalist app icon for "Birthday Greeting Campaign" plugin.
|
||||
Square format with rounded corners. Pink gradient background (#ec4899).
|
||||
White icon showing a birthday cake or gift box with a bow.
|
||||
Flat design, clean lines, cheerful yet professional style.
|
||||
The icon should be celebratory and friendly at small sizes.'''
|
||||
},
|
||||
{
|
||||
'filename': 'monthly-revenue-report.png',
|
||||
'prompt': '''A modern, minimalist app icon for "Monthly Revenue Report" plugin.
|
||||
Square format with rounded corners. Green gradient background (#10b981).
|
||||
White icon showing an upward trending chart or bar graph.
|
||||
Flat design, clean lines, professional business analytics style.
|
||||
The icon should convey growth and success at small sizes.'''
|
||||
},
|
||||
{
|
||||
'filename': 'appointment-reminder-24hr.png',
|
||||
'prompt': '''A modern, minimalist app icon for "Appointment Reminder" plugin.
|
||||
Square format with rounded corners. Amber/orange gradient background (#f59e0b).
|
||||
White icon showing a notification bell with a small red badge or alert dot.
|
||||
Flat design, clean lines, attention-grabbing yet professional style.
|
||||
The icon should convey alerts and reminders clearly at small sizes.'''
|
||||
},
|
||||
{
|
||||
'filename': 'inactive-customer-reengagement.png',
|
||||
'prompt': '''A modern, minimalist app icon for "Inactive Customer Re-engagement" plugin.
|
||||
Square format with rounded corners. Purple gradient background (#8b5cf6).
|
||||
White icon showing a heart with a circular refresh arrow around it.
|
||||
Flat design, clean lines, warm and welcoming professional style.
|
||||
The icon should convey customer care and return at small sizes.'''
|
||||
}
|
||||
]
|
||||
|
||||
def generate_image(prompt, filename):
|
||||
"""Generate an image using Imagen API"""
|
||||
print(f"\nGenerating {filename}...")
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"instances": [{
|
||||
"prompt": prompt
|
||||
}],
|
||||
"parameters": {
|
||||
"sampleCount": 1
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{API_URL}?key={API_KEY}",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=120
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
|
||||
# Check if there's an image in the predictions
|
||||
if 'predictions' in result and len(result['predictions']) > 0:
|
||||
prediction = result['predictions'][0]
|
||||
if 'bytesBase64Encoded' in prediction:
|
||||
# Extract and save the image
|
||||
image_data = base64.b64decode(prediction['bytesBase64Encoded'])
|
||||
output_path = os.path.join(OUTPUT_DIR, filename)
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(image_data)
|
||||
print(f"✓ Saved: {output_path} ({len(image_data)} bytes)")
|
||||
return True
|
||||
elif 'image' in prediction and 'bytesBase64Encoded' in prediction['image']:
|
||||
image_data = base64.b64decode(prediction['image']['bytesBase64Encoded'])
|
||||
output_path = os.path.join(OUTPUT_DIR, filename)
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(image_data)
|
||||
print(f"✓ Saved: {output_path} ({len(image_data)} bytes)")
|
||||
return True
|
||||
|
||||
print(f"✗ No image generated. Response: {str(result)[:300]}...")
|
||||
return False
|
||||
else:
|
||||
print(f"✗ API Error ({response.status_code}): {response.text[:500]}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error: {str(e)}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
# Create output directory
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
print("Starting logo generation with Imagen 4.0 API...")
|
||||
print("=" * 60)
|
||||
|
||||
success_count = 0
|
||||
for i, plugin in enumerate(plugins):
|
||||
if i > 0:
|
||||
# Add delay between requests to avoid rate limiting
|
||||
print("Waiting 2 seconds before next request...")
|
||||
time.sleep(2)
|
||||
|
||||
if generate_image(plugin['prompt'], plugin['filename']):
|
||||
success_count += 1
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"Generation complete: {success_count}/{len(plugins)} successful")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 706 B After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 43 KiB |
@@ -1,43 +0,0 @@
|
||||
import google.generativeai as genai
|
||||
import os
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
# Configure API Key
|
||||
genai.configure(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw")
|
||||
|
||||
# Try to initialize the model
|
||||
try:
|
||||
generation_model = genai.GenerativeModel('gemini-pro-vision')
|
||||
print("Model initialized successfully")
|
||||
|
||||
# Define prompt
|
||||
prompt = "A modern app icon with a blue background and white envelope symbol"
|
||||
print(f"Generating image for: '{prompt}'...")
|
||||
|
||||
# Try to generate
|
||||
response = generation_model.generate_content(prompt)
|
||||
|
||||
print(f"Response type: {type(response)}")
|
||||
print(f"Response: {response}")
|
||||
|
||||
if hasattr(response, 'parts') and response.parts:
|
||||
print(f"Parts: {response.parts}")
|
||||
if hasattr(response.parts[0], 'inline_data'):
|
||||
print("Has inline_data!")
|
||||
image_data = response.parts[0].inline_data.data
|
||||
image = Image.open(io.BytesIO(image_data))
|
||||
image.save("test_output.png")
|
||||
print("Success! Image saved to test_output.png")
|
||||
else:
|
||||
print("No inline_data attribute")
|
||||
print(f"Part attributes: {dir(response.parts[0])}")
|
||||
else:
|
||||
print("No parts in response or response.parts doesn't exist")
|
||||
if hasattr(response, 'text'):
|
||||
print(f"Response text: {response.text}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {type(e).__name__}: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
@@ -1,41 +0,0 @@
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
import os
|
||||
|
||||
# Setup Client
|
||||
client = genai.Client(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw")
|
||||
|
||||
# Define the Prompt
|
||||
prompt = "Create a simple modern app icon with a blue background and white envelope symbol, square format, flat design, minimalist"
|
||||
|
||||
print(f"Asking Gemini to generate: '{prompt}'...")
|
||||
|
||||
# Call the Gemini Model
|
||||
try:
|
||||
response = client.models.generate_content(
|
||||
model='gemini-2.5-flash-image',
|
||||
contents=prompt,
|
||||
config=types.GenerateContentConfig(
|
||||
response_modalities=["IMAGE"] # Tell Gemini to draw, not talk
|
||||
)
|
||||
)
|
||||
|
||||
# Save the Image
|
||||
if response.candidates[0].content.parts:
|
||||
for part in response.candidates[0].content.parts:
|
||||
if part.inline_data:
|
||||
image_data = part.inline_data.data
|
||||
image = Image.open(BytesIO(image_data))
|
||||
image.save("test_gemini_native.png")
|
||||
print("Success! Saved test_gemini_native.png")
|
||||
else:
|
||||
print("Model returned text instead of image:", part.text)
|
||||
else:
|
||||
print("No content parts in response")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
@@ -1,41 +0,0 @@
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
import os
|
||||
|
||||
# Initialize the Client
|
||||
client = genai.Client(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw")
|
||||
|
||||
# Define the prompt
|
||||
prompt = "A simple modern app icon with a blue background and white envelope symbol, square format, flat design"
|
||||
|
||||
print(f"Generating image for: '{prompt}'...")
|
||||
|
||||
# Call the API
|
||||
try:
|
||||
response = client.models.generate_images(
|
||||
model='gemini-2.5-flash-image',
|
||||
prompt=prompt,
|
||||
config=types.GenerateImagesConfig(
|
||||
number_of_images=1,
|
||||
aspect_ratio="1:1",
|
||||
safety_filter_level="BLOCK_LOW_AND_ABOVE",
|
||||
person_generation="ALLOW_ADULT"
|
||||
)
|
||||
)
|
||||
|
||||
# Handle the response
|
||||
for i, generated_image in enumerate(response.generated_images):
|
||||
# Convert raw bytes to an image
|
||||
image = Image.open(BytesIO(generated_image.image.image_bytes))
|
||||
|
||||
# Save to disk
|
||||
filename = f"test_generated_image_{i}.png"
|
||||
image.save(filename)
|
||||
print(f"Success! Saved {filename}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error occurred: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
};
|
||||
456
frontend/src/components/EmailTemplateForm.tsx
Normal 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;
|
||||
112
frontend/src/components/EmailTemplateSelector.tsx
Normal 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;
|
||||
@@ -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'}`}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
106
frontend/src/hooks/useTicketEmailSettings.ts
Normal 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 };
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
535
frontend/src/pages/EmailTemplates.tsx
Normal file
@@ -0,0 +1,535 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Mail,
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Copy,
|
||||
Eye,
|
||||
X,
|
||||
Calendar,
|
||||
Bell,
|
||||
CheckCircle,
|
||||
Megaphone,
|
||||
FileText,
|
||||
BarChart3,
|
||||
Package,
|
||||
AlertTriangle
|
||||
} from 'lucide-react';
|
||||
import api from '../api/client';
|
||||
import { EmailTemplate, EmailTemplateCategory } from '../types';
|
||||
import EmailTemplateForm from '../components/EmailTemplateForm';
|
||||
|
||||
// Category icon mapping
|
||||
const categoryIcons: Record<EmailTemplateCategory, React.ReactNode> = {
|
||||
APPOINTMENT: <Calendar className="h-4 w-4" />,
|
||||
REMINDER: <Bell className="h-4 w-4" />,
|
||||
CONFIRMATION: <CheckCircle className="h-4 w-4" />,
|
||||
MARKETING: <Megaphone className="h-4 w-4" />,
|
||||
NOTIFICATION: <FileText className="h-4 w-4" />,
|
||||
REPORT: <BarChart3 className="h-4 w-4" />,
|
||||
OTHER: <Package className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
// Category colors
|
||||
const categoryColors: Record<EmailTemplateCategory, string> = {
|
||||
APPOINTMENT: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
REMINDER: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
CONFIRMATION: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
|
||||
MARKETING: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
|
||||
NOTIFICATION: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300',
|
||||
REPORT: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300',
|
||||
OTHER: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
||||
};
|
||||
|
||||
const EmailTemplates: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<EmailTemplateCategory | 'ALL'>('ALL');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [templateToDelete, setTemplateToDelete] = useState<EmailTemplate | null>(null);
|
||||
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
||||
const [previewTemplate, setPreviewTemplate] = useState<EmailTemplate | null>(null);
|
||||
|
||||
// Fetch email templates
|
||||
const { data: templates = [], isLoading, error } = useQuery<EmailTemplate[]>({
|
||||
queryKey: ['email-templates'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/api/email-templates/');
|
||||
return data.map((t: any) => ({
|
||||
id: String(t.id),
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
subject: t.subject,
|
||||
htmlContent: t.html_content,
|
||||
textContent: t.text_content,
|
||||
scope: t.scope,
|
||||
isDefault: t.is_default,
|
||||
category: t.category,
|
||||
previewContext: t.preview_context,
|
||||
createdBy: t.created_by,
|
||||
createdByName: t.created_by_name,
|
||||
createdAt: t.created_at,
|
||||
updatedAt: t.updated_at,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// Delete template mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (templateId: string) => {
|
||||
await api.delete(`/api/email-templates/${templateId}/`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
|
||||
setShowDeleteModal(false);
|
||||
setTemplateToDelete(null);
|
||||
},
|
||||
});
|
||||
|
||||
// Duplicate template mutation
|
||||
const duplicateMutation = useMutation({
|
||||
mutationFn: async (templateId: string) => {
|
||||
const { data } = await api.post(`/api/email-templates/${templateId}/duplicate/`);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Filter templates
|
||||
const filteredTemplates = useMemo(() => {
|
||||
let result = templates;
|
||||
|
||||
// Filter by category
|
||||
if (selectedCategory !== 'ALL') {
|
||||
result = result.filter(t => t.category === selectedCategory);
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(t =>
|
||||
t.name.toLowerCase().includes(query) ||
|
||||
t.description.toLowerCase().includes(query) ||
|
||||
t.subject.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [templates, selectedCategory, searchQuery]);
|
||||
|
||||
const handleEdit = (template: EmailTemplate) => {
|
||||
setEditingTemplate(template);
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = (template: EmailTemplate) => {
|
||||
setTemplateToDelete(template);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleDuplicate = (template: EmailTemplate) => {
|
||||
duplicateMutation.mutate(template.id);
|
||||
};
|
||||
|
||||
const handlePreview = (template: EmailTemplate) => {
|
||||
setPreviewTemplate(template);
|
||||
setShowPreviewModal(true);
|
||||
};
|
||||
|
||||
const handleFormClose = () => {
|
||||
setShowCreateModal(false);
|
||||
setEditingTemplate(null);
|
||||
};
|
||||
|
||||
const handleFormSuccess = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
|
||||
handleFormClose();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<p className="text-red-800 dark:text-red-300">
|
||||
{t('common.error')}: {error instanceof Error ? error.message : 'Unknown error'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Mail className="h-7 w-7 text-brand-600" />
|
||||
{t('emailTemplates.title', 'Email Templates')}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('emailTemplates.description', 'Create and manage reusable email templates for your plugins')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
{t('emailTemplates.create', 'Create Template')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 shadow-sm">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
{/* Search Bar */}
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('emailTemplates.search', 'Search templates...')}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-5 w-5 text-gray-400" />
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value as EmailTemplateCategory | 'ALL')}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
>
|
||||
<option value="ALL">{t('emailTemplates.allCategories', 'All Categories')}</option>
|
||||
<option value="APPOINTMENT">{t('emailTemplates.categoryAppointment', 'Appointment')}</option>
|
||||
<option value="REMINDER">{t('emailTemplates.categoryReminder', 'Reminder')}</option>
|
||||
<option value="CONFIRMATION">{t('emailTemplates.categoryConfirmation', 'Confirmation')}</option>
|
||||
<option value="MARKETING">{t('emailTemplates.categoryMarketing', 'Marketing')}</option>
|
||||
<option value="NOTIFICATION">{t('emailTemplates.categoryNotification', 'Notification')}</option>
|
||||
<option value="REPORT">{t('emailTemplates.categoryReport', 'Report')}</option>
|
||||
<option value="OTHER">{t('emailTemplates.categoryOther', 'Other')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Filters Summary */}
|
||||
{(searchQuery || selectedCategory !== 'ALL') && (
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('emailTemplates.showing', 'Showing')} {filteredTemplates.length} {t('emailTemplates.results', 'results')}
|
||||
</span>
|
||||
{(searchQuery || selectedCategory !== 'ALL') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setSelectedCategory('ALL');
|
||||
}}
|
||||
className="text-sm text-brand-600 dark:text-brand-400 hover:underline"
|
||||
>
|
||||
{t('common.clearAll', 'Clear all')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Templates List */}
|
||||
{filteredTemplates.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<Mail className="h-12 w-12 mx-auto text-gray-400 mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-4">
|
||||
{searchQuery || selectedCategory !== 'ALL'
|
||||
? t('emailTemplates.noResults', 'No templates found matching your criteria')
|
||||
: t('emailTemplates.empty', 'No email templates yet')}
|
||||
</p>
|
||||
{!searchQuery && selectedCategory === 'ALL' && (
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('emailTemplates.createFirst', 'Create your first template')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredTemplates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow overflow-hidden"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
{/* Template Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{template.name}
|
||||
</h3>
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium ${categoryColors[template.category]}`}>
|
||||
{categoryIcons[template.category]}
|
||||
{template.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{template.description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
{template.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500 mb-3">
|
||||
<span className="font-medium">{t('emailTemplates.subject', 'Subject')}:</span> {template.subject}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium">
|
||||
{t('emailTemplates.updatedAt', 'Updated')}:
|
||||
</span>
|
||||
<span>
|
||||
{new Date(template.updatedAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{template.htmlContent && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300">
|
||||
HTML
|
||||
</span>
|
||||
)}
|
||||
{template.textContent && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
|
||||
Text
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<button
|
||||
onClick={() => handlePreview(template)}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title={t('emailTemplates.preview', 'Preview')}
|
||||
>
|
||||
<Eye className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDuplicate(template)}
|
||||
disabled={duplicateMutation.isPending}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
|
||||
title={t('emailTemplates.duplicate', 'Duplicate')}
|
||||
>
|
||||
<Copy className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(template)}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title={t('common.edit', 'Edit')}
|
||||
>
|
||||
<Edit2 className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(template)}
|
||||
className="p-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||
title={t('common.delete', 'Delete')}
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{showCreateModal && (
|
||||
<EmailTemplateForm
|
||||
template={editingTemplate}
|
||||
onClose={handleFormClose}
|
||||
onSuccess={handleFormSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteModal && templateToDelete && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full overflow-hidden">
|
||||
{/* Modal Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-red-100 dark:bg-red-900/30 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('emailTemplates.confirmDelete', 'Delete Template')}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDeleteModal(false);
|
||||
setTemplateToDelete(null);
|
||||
}}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="p-6">
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{t('emailTemplates.deleteWarning', 'Are you sure you want to delete')} <span className="font-semibold text-gray-900 dark:text-white">{templateToDelete.name}</span>?
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('emailTemplates.deleteNote', 'This action cannot be undone. Plugins using this template may no longer work correctly.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDeleteModal(false);
|
||||
setTemplateToDelete(null);
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors font-medium"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(templateToDelete.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
{t('common.deleting', 'Deleting...')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{t('common.delete', 'Delete')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview Modal */}
|
||||
{showPreviewModal && previewTemplate && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Modal Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('emailTemplates.preview', 'Preview')}: {previewTemplate.name}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowPreviewModal(false);
|
||||
setPreviewTemplate(null);
|
||||
}}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="p-6 overflow-y-auto flex-1">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('emailTemplates.subject', 'Subject')}
|
||||
</label>
|
||||
<div className="p-3 bg-gray-100 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white">
|
||||
{previewTemplate.subject}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{previewTemplate.htmlContent && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('emailTemplates.htmlPreview', 'HTML Preview')}
|
||||
</label>
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<iframe
|
||||
srcDoc={previewTemplate.htmlContent}
|
||||
className="w-full h-96 bg-white"
|
||||
title="Email Preview"
|
||||
sandbox="allow-same-origin"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewTemplate.textContent && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('emailTemplates.textPreview', 'Plain Text Preview')}
|
||||
</label>
|
||||
<pre className="p-4 bg-gray-100 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white text-sm whitespace-pre-wrap font-mono overflow-auto max-h-48">
|
||||
{previewTemplate.textContent}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowPreviewModal(false);
|
||||
setPreviewTemplate(null);
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors font-medium"
|
||||
>
|
||||
{t('common.close', 'Close')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowPreviewModal(false);
|
||||
handleEdit(previewTemplate);
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
{t('common.edit', 'Edit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailTemplates;
|
||||
@@ -30,7 +30,19 @@ const LoginPage: React.FC = () => {
|
||||
{ username, password },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
const user = data.user;
|
||||
// Check if MFA is required
|
||||
if (data.mfa_required) {
|
||||
// Store MFA challenge info in sessionStorage and redirect to MFA page
|
||||
sessionStorage.setItem('mfa_challenge', JSON.stringify({
|
||||
user_id: data.user_id,
|
||||
mfa_methods: data.mfa_methods,
|
||||
phone_last_4: data.phone_last_4,
|
||||
}));
|
||||
navigate('/mfa-verify');
|
||||
return;
|
||||
}
|
||||
|
||||
const user = data.user!;
|
||||
const currentHostname = window.location.hostname;
|
||||
const currentPort = window.location.port;
|
||||
const portStr = currentPort ? `:${currentPort}` : '';
|
||||
|
||||
620
frontend/src/pages/MFASetupPage.tsx
Normal file
@@ -0,0 +1,620 @@
|
||||
/**
|
||||
* MFA Setup Page
|
||||
* Allows users to enable/disable 2FA, manage phone verification, authenticator app, and backup codes
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
getMFAStatus,
|
||||
sendPhoneVerification,
|
||||
verifyPhone,
|
||||
enableSMSMFA,
|
||||
setupTOTP,
|
||||
verifyTOTPSetup,
|
||||
generateBackupCodes,
|
||||
disableMFA,
|
||||
listTrustedDevices,
|
||||
revokeTrustedDevice,
|
||||
revokeAllTrustedDevices,
|
||||
MFAStatus,
|
||||
TrustedDevice,
|
||||
} from '../api/mfa';
|
||||
import {
|
||||
Shield,
|
||||
Smartphone,
|
||||
Key,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Trash2,
|
||||
Monitor,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const MFASetupPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// State
|
||||
const [phoneNumber, setPhoneNumber] = useState('');
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [showBackupCodes, setShowBackupCodes] = useState(false);
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [showDisableModal, setShowDisableModal] = useState(false);
|
||||
const [disablePassword, setDisablePassword] = useState('');
|
||||
const [totpSetupData, setTotpSetupData] = useState<{
|
||||
secret: string;
|
||||
qr_code: string;
|
||||
provisioning_uri: string;
|
||||
} | null>(null);
|
||||
const [phoneSent, setPhoneSent] = useState(false);
|
||||
|
||||
// Queries
|
||||
const { data: mfaStatus, isLoading: statusLoading } = useQuery({
|
||||
queryKey: ['mfa-status'],
|
||||
queryFn: getMFAStatus,
|
||||
});
|
||||
|
||||
const { data: trustedDevicesData, isLoading: devicesLoading } = useQuery({
|
||||
queryKey: ['trusted-devices'],
|
||||
queryFn: listTrustedDevices,
|
||||
enabled: !!mfaStatus?.mfa_enabled,
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const sendPhoneCodeMutation = useMutation({
|
||||
mutationFn: sendPhoneVerification,
|
||||
onSuccess: () => {
|
||||
setPhoneSent(true);
|
||||
toast.success('Verification code sent!');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.error || 'Failed to send code');
|
||||
},
|
||||
});
|
||||
|
||||
const verifyPhoneMutation = useMutation({
|
||||
mutationFn: verifyPhone,
|
||||
onSuccess: () => {
|
||||
toast.success('Phone verified!');
|
||||
queryClient.invalidateQueries({ queryKey: ['mfa-status'] });
|
||||
setVerificationCode('');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.error || 'Invalid code');
|
||||
},
|
||||
});
|
||||
|
||||
const enableSMSMutation = useMutation({
|
||||
mutationFn: enableSMSMFA,
|
||||
onSuccess: (data) => {
|
||||
toast.success('SMS MFA enabled!');
|
||||
if (data.backup_codes) {
|
||||
setBackupCodes(data.backup_codes);
|
||||
setShowBackupCodes(true);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['mfa-status'] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.error || 'Failed to enable SMS MFA');
|
||||
},
|
||||
});
|
||||
|
||||
const setupTOTPMutation = useMutation({
|
||||
mutationFn: setupTOTP,
|
||||
onSuccess: (data) => {
|
||||
setTotpSetupData({
|
||||
secret: data.secret,
|
||||
qr_code: data.qr_code,
|
||||
provisioning_uri: data.provisioning_uri,
|
||||
});
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.error || 'Failed to setup authenticator');
|
||||
},
|
||||
});
|
||||
|
||||
const verifyTOTPMutation = useMutation({
|
||||
mutationFn: verifyTOTPSetup,
|
||||
onSuccess: (data) => {
|
||||
toast.success('Authenticator app configured!');
|
||||
if (data.backup_codes) {
|
||||
setBackupCodes(data.backup_codes);
|
||||
setShowBackupCodes(true);
|
||||
}
|
||||
setTotpSetupData(null);
|
||||
setTotpCode('');
|
||||
queryClient.invalidateQueries({ queryKey: ['mfa-status'] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.error || 'Invalid code');
|
||||
},
|
||||
});
|
||||
|
||||
const generateBackupCodesMutation = useMutation({
|
||||
mutationFn: generateBackupCodes,
|
||||
onSuccess: (data) => {
|
||||
setBackupCodes(data.backup_codes);
|
||||
setShowBackupCodes(true);
|
||||
queryClient.invalidateQueries({ queryKey: ['mfa-status'] });
|
||||
toast.success('New backup codes generated!');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.error || 'Failed to generate codes');
|
||||
},
|
||||
});
|
||||
|
||||
const disableMFAMutation = useMutation({
|
||||
mutationFn: disableMFA,
|
||||
onSuccess: () => {
|
||||
toast.success('Two-factor authentication disabled');
|
||||
setShowDisableModal(false);
|
||||
setDisablePassword('');
|
||||
queryClient.invalidateQueries({ queryKey: ['mfa-status'] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.error || 'Invalid password');
|
||||
},
|
||||
});
|
||||
|
||||
const revokeDeviceMutation = useMutation({
|
||||
mutationFn: revokeTrustedDevice,
|
||||
onSuccess: () => {
|
||||
toast.success('Device trust revoked');
|
||||
queryClient.invalidateQueries({ queryKey: ['trusted-devices'] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.error || 'Failed to revoke device');
|
||||
},
|
||||
});
|
||||
|
||||
const revokeAllDevicesMutation = useMutation({
|
||||
mutationFn: revokeAllTrustedDevices,
|
||||
onSuccess: () => {
|
||||
toast.success('All devices revoked');
|
||||
queryClient.invalidateQueries({ queryKey: ['trusted-devices'] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.error || 'Failed to revoke devices');
|
||||
},
|
||||
});
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success('Copied to clipboard!');
|
||||
};
|
||||
|
||||
if (statusLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-brand-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const trustedDevices = trustedDevicesData?.devices || [];
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-brand-100 dark:bg-brand-900/30 rounded-full">
|
||||
<Shield className="h-8 w-8 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Two-Factor Authentication
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Add an extra layer of security to your account
|
||||
</p>
|
||||
</div>
|
||||
{mfaStatus?.mfa_enabled && (
|
||||
<div className="ml-auto flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
<span className="font-medium">Enabled</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Backup Codes Modal */}
|
||||
{showBackupCodes && backupCodes.length > 0 && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl max-w-md w-full p-6 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Key className="h-6 w-6 text-amber-500" />
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Save Your Backup Codes
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Save these codes in a safe place. Each code can only be used once to access your account if you lose your phone.
|
||||
</p>
|
||||
<div className="bg-gray-100 dark:bg-gray-700 rounded-lg p-4 font-mono text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{backupCodes.map((code, index) => (
|
||||
<div key={index} className="text-gray-800 dark:text-gray-200">
|
||||
{code}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => copyToClipboard(backupCodes.join('\n'))}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowBackupCodes(false)}
|
||||
className="flex-1 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
I've Saved These
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disable MFA Modal */}
|
||||
{showDisableModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl max-w-md w-full p-6 space-y-4">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Disable Two-Factor Authentication
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Enter your password to disable 2FA. This will make your account less secure.
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
value={disablePassword}
|
||||
onChange={(e) => setDisablePassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDisableModal(false);
|
||||
setDisablePassword('');
|
||||
}}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => disableMFAMutation.mutate({ password: disablePassword })}
|
||||
disabled={!disablePassword || disableMFAMutation.isPending}
|
||||
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{disableMFAMutation.isPending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
|
||||
) : (
|
||||
'Disable 2FA'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SMS Setup */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Smartphone className="h-6 w-6 text-blue-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
SMS Authentication
|
||||
</h2>
|
||||
{mfaStatus?.phone_verified && (
|
||||
<span className="ml-auto text-sm text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
Phone verified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!mfaStatus?.phone_verified ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Verify your phone number to receive verification codes via SMS.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="tel"
|
||||
value={phoneNumber}
|
||||
onChange={(e) => setPhoneNumber(e.target.value)}
|
||||
placeholder="+1 (555) 000-0000"
|
||||
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
onClick={() => sendPhoneCodeMutation.mutate(phoneNumber)}
|
||||
disabled={!phoneNumber || sendPhoneCodeMutation.isPending}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{sendPhoneCodeMutation.isPending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
'Send Code'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{phoneSent && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
placeholder="6-digit code"
|
||||
maxLength={6}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
onClick={() => verifyPhoneMutation.mutate(verificationCode)}
|
||||
disabled={verificationCode.length !== 6 || verifyPhoneMutation.isPending}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{verifyPhoneMutation.isPending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
'Verify'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Your phone ending in {mfaStatus.phone_last_4} is verified.
|
||||
{mfaStatus.mfa_method?.includes('SMS') || mfaStatus.mfa_method === 'BOTH'
|
||||
? ' SMS authentication is enabled.'
|
||||
: ' Enable SMS to use it for verification.'}
|
||||
</p>
|
||||
{!(mfaStatus.mfa_method?.includes('SMS') || mfaStatus.mfa_method === 'BOTH') && (
|
||||
<button
|
||||
onClick={() => enableSMSMutation.mutate()}
|
||||
disabled={enableSMSMutation.isPending}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{enableSMSMutation.isPending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
'Enable SMS Authentication'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* TOTP Setup */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Shield className="h-6 w-6 text-purple-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Authenticator App
|
||||
</h2>
|
||||
{mfaStatus?.totp_verified && (
|
||||
<span className="ml-auto text-sm text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
Configured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!mfaStatus?.totp_verified ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Use an authenticator app like Google Authenticator, Authy, or 1Password to generate verification codes.
|
||||
</p>
|
||||
{!totpSetupData ? (
|
||||
<button
|
||||
onClick={() => setupTOTPMutation.mutate()}
|
||||
disabled={setupTOTPMutation.isPending}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{setupTOTPMutation.isPending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
'Set Up Authenticator App'
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col md:flex-row gap-6 items-center">
|
||||
<div className="bg-white p-4 rounded-lg">
|
||||
<img
|
||||
src={totpSetupData.qr_code}
|
||||
alt="QR Code"
|
||||
className="w-48 h-48"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
1. Scan this QR code with your authenticator app
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
2. Or manually enter this key:
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded font-mono text-sm break-all">
|
||||
{totpSetupData.secret}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(totpSetupData.secret)}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||
placeholder="Enter 6-digit code"
|
||||
maxLength={6}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
onClick={() => verifyTOTPMutation.mutate(totpCode)}
|
||||
disabled={totpCode.length !== 6 || verifyTOTPMutation.isPending}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{verifyTOTPMutation.isPending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
'Verify'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setTotpSetupData(null)}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Your authenticator app is configured and ready to use.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Backup Codes */}
|
||||
{mfaStatus?.mfa_enabled && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Key className="h-6 w-6 text-amber-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Backup Codes
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Backup codes can be used to access your account if you lose your phone.
|
||||
You have <strong>{mfaStatus.backup_codes_count}</strong> codes remaining.
|
||||
{mfaStatus.backup_codes_generated_at && (
|
||||
<span className="block mt-1 text-xs">
|
||||
Generated on {new Date(mfaStatus.backup_codes_generated_at).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => generateBackupCodesMutation.mutate()}
|
||||
disabled={generateBackupCodesMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{generateBackupCodesMutation.isPending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Generate New Codes
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trusted Devices */}
|
||||
{mfaStatus?.mfa_enabled && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Monitor className="h-6 w-6 text-gray-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Trusted Devices
|
||||
</h2>
|
||||
</div>
|
||||
{trustedDevices.length > 0 && (
|
||||
<button
|
||||
onClick={() => revokeAllDevicesMutation.mutate()}
|
||||
disabled={revokeAllDevicesMutation.isPending}
|
||||
className="text-sm text-red-600 hover:text-red-700 dark:text-red-400"
|
||||
>
|
||||
Revoke All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{devicesLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : trustedDevices.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
No trusted devices. When you log in and check "Trust this device", it will appear here.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{trustedDevices.map((device) => (
|
||||
<div
|
||||
key={device.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Monitor className="h-5 w-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{device.name || 'Unknown Device'}
|
||||
{device.is_current && (
|
||||
<span className="ml-2 text-xs text-green-600 dark:text-green-400">
|
||||
(Current)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{device.ip_address} • Last used {new Date(device.last_used_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => revokeDeviceMutation.mutate(device.id)}
|
||||
disabled={revokeDeviceMutation.isPending}
|
||||
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disable 2FA */}
|
||||
{mfaStatus?.mfa_enabled && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 rounded-xl border border-red-200 dark:border-red-800 p-6">
|
||||
<h2 className="text-lg font-semibold text-red-800 dark:text-red-200 mb-2">
|
||||
Disable Two-Factor Authentication
|
||||
</h2>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 mb-4">
|
||||
Disabling 2FA will make your account less secure. You will no longer need a verification code to log in.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowDisableModal(true)}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Disable 2FA
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MFASetupPage;
|
||||
431
frontend/src/pages/MFAVerifyPage.tsx
Normal file
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* MFA Verification Page
|
||||
* Shown when user has MFA enabled and needs to complete verification during login
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { sendMFALoginCode, verifyMFALogin } from '../api/mfa';
|
||||
import { setCookie } from '../utils/cookies';
|
||||
import SmoothScheduleLogo from '../components/SmoothScheduleLogo';
|
||||
import {
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Shield,
|
||||
Smartphone,
|
||||
Key,
|
||||
ArrowLeft,
|
||||
CheckCircle
|
||||
} from 'lucide-react';
|
||||
|
||||
interface MFAChallenge {
|
||||
user_id: number;
|
||||
mfa_methods: ('SMS' | 'TOTP' | 'BACKUP')[];
|
||||
phone_last_4: string | null;
|
||||
}
|
||||
|
||||
const MFAVerifyPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [challenge, setChallenge] = useState<MFAChallenge | null>(null);
|
||||
const [selectedMethod, setSelectedMethod] = useState<'SMS' | 'TOTP' | 'BACKUP' | null>(null);
|
||||
const [code, setCode] = useState(['', '', '', '', '', '']);
|
||||
const [backupCode, setBackupCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [smsSent, setSmsSent] = useState(false);
|
||||
const [trustDevice, setTrustDevice] = useState(false);
|
||||
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Get MFA challenge from sessionStorage
|
||||
const storedChallenge = sessionStorage.getItem('mfa_challenge');
|
||||
if (!storedChallenge) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(storedChallenge) as MFAChallenge;
|
||||
setChallenge(parsed);
|
||||
|
||||
// Default to TOTP if available, otherwise SMS
|
||||
if (parsed.mfa_methods.includes('TOTP')) {
|
||||
setSelectedMethod('TOTP');
|
||||
} else if (parsed.mfa_methods.includes('SMS')) {
|
||||
setSelectedMethod('SMS');
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
const handleSendSMS = async () => {
|
||||
if (!challenge) return;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await sendMFALoginCode(challenge.user_id, 'SMS');
|
||||
setSmsSent(true);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to send SMS code');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCodeChange = (index: number, value: string) => {
|
||||
// Only allow digits
|
||||
if (value && !/^\d$/.test(value)) return;
|
||||
|
||||
const newCode = [...code];
|
||||
newCode[index] = value;
|
||||
setCode(newCode);
|
||||
|
||||
// Auto-focus next input
|
||||
if (value && index < 5) {
|
||||
inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Backspace' && !code[index] && index > 0) {
|
||||
inputRefs.current[index - 1]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent) => {
|
||||
e.preventDefault();
|
||||
const pastedData = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6);
|
||||
const newCode = [...code];
|
||||
for (let i = 0; i < pastedData.length; i++) {
|
||||
newCode[i] = pastedData[i];
|
||||
}
|
||||
setCode(newCode);
|
||||
// Focus the next empty input or the last one
|
||||
const nextEmptyIndex = newCode.findIndex(c => !c);
|
||||
inputRefs.current[nextEmptyIndex === -1 ? 5 : nextEmptyIndex]?.focus();
|
||||
};
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!challenge || !selectedMethod) return;
|
||||
|
||||
const verificationCode = selectedMethod === 'BACKUP'
|
||||
? backupCode.trim()
|
||||
: code.join('');
|
||||
|
||||
if (selectedMethod !== 'BACKUP' && verificationCode.length !== 6) {
|
||||
setError('Please enter a 6-digit code');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedMethod === 'BACKUP' && !verificationCode) {
|
||||
setError('Please enter a backup code');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await verifyMFALogin(
|
||||
challenge.user_id,
|
||||
verificationCode,
|
||||
selectedMethod,
|
||||
trustDevice
|
||||
);
|
||||
|
||||
// Clear MFA challenge from storage
|
||||
sessionStorage.removeItem('mfa_challenge');
|
||||
|
||||
// Store tokens
|
||||
setCookie('access_token', response.access, 7);
|
||||
setCookie('refresh_token', response.refresh, 30);
|
||||
|
||||
// Get redirect info from user
|
||||
const user = response.user;
|
||||
const currentHostname = window.location.hostname;
|
||||
const currentPort = window.location.port;
|
||||
const portStr = currentPort ? `:${currentPort}` : '';
|
||||
|
||||
// Determine target subdomain
|
||||
const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role);
|
||||
let targetSubdomain: string | null = null;
|
||||
|
||||
if (isPlatformUser) {
|
||||
targetSubdomain = 'platform';
|
||||
} else if (user.business_subdomain) {
|
||||
targetSubdomain = user.business_subdomain;
|
||||
}
|
||||
|
||||
// Check if we need to redirect
|
||||
const isOnTargetSubdomain = currentHostname === `${targetSubdomain}.lvh.me`;
|
||||
const needsRedirect = targetSubdomain && !isOnTargetSubdomain;
|
||||
|
||||
if (needsRedirect) {
|
||||
window.location.href = `http://${targetSubdomain}.lvh.me${portStr}/?access_token=${response.access}&refresh_token=${response.refresh}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to dashboard
|
||||
navigate('/');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Invalid verification code');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getMethodIcon = (method: string) => {
|
||||
switch (method) {
|
||||
case 'SMS':
|
||||
return <Smartphone className="h-5 w-5" />;
|
||||
case 'TOTP':
|
||||
return <Shield className="h-5 w-5" />;
|
||||
case 'BACKUP':
|
||||
return <Key className="h-5 w-5" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getMethodLabel = (method: string) => {
|
||||
switch (method) {
|
||||
case 'SMS':
|
||||
return challenge?.phone_last_4
|
||||
? `SMS to ***-***-${challenge.phone_last_4}`
|
||||
: 'SMS Code';
|
||||
case 'TOTP':
|
||||
return 'Authenticator App';
|
||||
case 'BACKUP':
|
||||
return 'Backup Code';
|
||||
default:
|
||||
return method;
|
||||
}
|
||||
};
|
||||
|
||||
if (!challenge) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-brand-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-50 dark:bg-gray-900 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="p-3 bg-brand-100 dark:bg-brand-900/30 rounded-full">
|
||||
<Shield className="h-8 w-8 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Two-Factor Authentication
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Enter a verification code to complete login
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-6 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 border border-red-100 dark:border-red-800/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 dark:text-red-400 flex-shrink-0" />
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Method Selection */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
{/* Method Tabs */}
|
||||
{challenge.mfa_methods.length > 1 && (
|
||||
<div className="flex gap-2 mb-6">
|
||||
{challenge.mfa_methods.map((method) => (
|
||||
<button
|
||||
key={method}
|
||||
onClick={() => {
|
||||
setSelectedMethod(method);
|
||||
setCode(['', '', '', '', '', '']);
|
||||
setBackupCode('');
|
||||
setError('');
|
||||
}}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedMethod === method
|
||||
? 'bg-brand-100 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{getMethodIcon(method)}
|
||||
<span className="hidden sm:inline">{method === 'TOTP' ? 'App' : method === 'BACKUP' ? 'Backup' : 'SMS'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SMS Method */}
|
||||
{selectedMethod === 'SMS' && (
|
||||
<div className="space-y-4">
|
||||
{!smsSent ? (
|
||||
<>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 text-center">
|
||||
We'll send a verification code to your phone ending in{' '}
|
||||
<span className="font-medium">{challenge.phone_last_4}</span>
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSendSMS}
|
||||
disabled={loading}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-brand-600 hover:bg-brand-700 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Smartphone className="h-5 w-5" />
|
||||
Send Code
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-center gap-2 text-green-600 dark:text-green-400 mb-4">
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
<span className="text-sm">Code sent!</span>
|
||||
</div>
|
||||
<div className="flex justify-center gap-2" onPaste={handlePaste}>
|
||||
{code.map((digit, index) => (
|
||||
<input
|
||||
key={index}
|
||||
ref={(el) => { inputRefs.current[index] = el; }}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={1}
|
||||
value={digit}
|
||||
onChange={(e) => handleCodeChange(index, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(index, e)}
|
||||
className="w-12 h-14 text-center text-2xl font-semibold border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:border-brand-500 focus:ring-brand-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
autoFocus={index === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSendSMS}
|
||||
disabled={loading}
|
||||
className="text-sm text-brand-600 dark:text-brand-400 hover:underline block mx-auto mt-2"
|
||||
>
|
||||
Resend code
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TOTP Method */}
|
||||
{selectedMethod === 'TOTP' && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 text-center">
|
||||
Enter the 6-digit code from your authenticator app
|
||||
</p>
|
||||
<div className="flex justify-center gap-2" onPaste={handlePaste}>
|
||||
{code.map((digit, index) => (
|
||||
<input
|
||||
key={index}
|
||||
ref={(el) => { inputRefs.current[index] = el; }}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={1}
|
||||
value={digit}
|
||||
onChange={(e) => handleCodeChange(index, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(index, e)}
|
||||
className="w-12 h-14 text-center text-2xl font-semibold border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:border-brand-500 focus:ring-brand-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
autoFocus={index === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backup Code Method */}
|
||||
{selectedMethod === 'BACKUP' && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 text-center">
|
||||
Enter one of your backup codes
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={backupCode}
|
||||
onChange={(e) => setBackupCode(e.target.value.toUpperCase())}
|
||||
placeholder="XXXX-XXXX"
|
||||
className="w-full text-center text-lg font-mono tracking-wider border-2 border-gray-300 dark:border-gray-600 rounded-lg py-3 focus:border-brand-500 focus:ring-brand-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 text-center">
|
||||
Each backup code can only be used once
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trust Device Checkbox */}
|
||||
<div className="mt-6 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="trust-device"
|
||||
checked={trustDevice}
|
||||
onChange={(e) => setTrustDevice(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<label htmlFor="trust-device" className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Trust this device for 30 days
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Verify Button */}
|
||||
{((selectedMethod === 'SMS' && smsSent) || selectedMethod === 'TOTP' || selectedMethod === 'BACKUP') && (
|
||||
<button
|
||||
onClick={handleVerify}
|
||||
disabled={loading}
|
||||
className="w-full mt-6 flex items-center justify-center gap-2 px-4 py-3 bg-brand-600 hover:bg-brand-700 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
'Verify'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Back to Login */}
|
||||
<button
|
||||
onClick={() => {
|
||||
sessionStorage.removeItem('mfa_challenge');
|
||||
navigate('/login');
|
||||
}}
|
||||
className="mt-6 flex items-center justify-center gap-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white mx-auto"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to login
|
||||
</button>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-8 flex justify-center">
|
||||
<SmoothScheduleLogo className="h-6 w-6 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MFAVerifyPage;
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import api from '../api/client';
|
||||
import { PluginInstallation, PluginCategory } from '../types';
|
||||
import EmailTemplateSelector from '../components/EmailTemplateSelector';
|
||||
|
||||
// Category icon mapping
|
||||
const categoryIcons: Record<PluginCategory, React.ReactNode> = {
|
||||
@@ -689,7 +690,13 @@ const MyPlugins: React.FC = () => {
|
||||
{variable.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
|
||||
{variable.type === 'textarea' ? (
|
||||
{variable.type === 'email_template' ? (
|
||||
<EmailTemplateSelector
|
||||
value={configValues[key] || variable.default}
|
||||
onChange={(templateId) => setConfigValues({ ...configValues, [key]: templateId })}
|
||||
required={variable.required}
|
||||
/>
|
||||
) : variable.type === 'textarea' ? (
|
||||
<textarea
|
||||
value={configValues[key] !== undefined ? configValues[key] : (variable.default ? unescapeString(variable.default) : '')}
|
||||
onChange={(e) => setConfigValues({ ...configValues, [key]: e.target.value })}
|
||||
|
||||
@@ -25,6 +25,12 @@ import {
|
||||
Lock,
|
||||
Users,
|
||||
ExternalLink,
|
||||
Mail,
|
||||
Clock,
|
||||
Server,
|
||||
Play,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
usePlatformSettings,
|
||||
@@ -42,14 +48,23 @@ import {
|
||||
usePlatformOAuthSettings,
|
||||
useUpdatePlatformOAuthSettings,
|
||||
} from '../../hooks/usePlatformOAuth';
|
||||
import {
|
||||
useTicketEmailSettings,
|
||||
useUpdateTicketEmailSettings,
|
||||
useTestImapConnection,
|
||||
useTestSmtpConnection,
|
||||
useFetchEmailsNow,
|
||||
} from '../../hooks/useTicketEmailSettings';
|
||||
import { Send } from 'lucide-react';
|
||||
|
||||
type TabType = 'stripe' | 'tiers' | 'oauth';
|
||||
type TabType = 'general' | 'stripe' | 'tiers' | 'oauth';
|
||||
|
||||
const PlatformSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('stripe');
|
||||
const [activeTab, setActiveTab] = useState<TabType>('general');
|
||||
|
||||
const tabs: { id: TabType; label: string; icon: React.ElementType }[] = [
|
||||
{ id: 'general', label: t('platform.settings.general', 'General'), icon: Settings },
|
||||
{ id: 'stripe', label: 'Stripe', icon: CreditCard },
|
||||
{ id: 'tiers', label: t('platform.settings.tiersPricing'), icon: Layers },
|
||||
{ id: 'oauth', label: t('platform.settings.oauthProviders'), icon: Users },
|
||||
@@ -94,6 +109,7 @@ const PlatformSettings: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'general' && <GeneralSettingsTab />}
|
||||
{activeTab === 'stripe' && <StripeSettingsTab />}
|
||||
{activeTab === 'tiers' && <TiersSettingsTab />}
|
||||
{activeTab === 'oauth' && <OAuthSettingsTab />}
|
||||
@@ -101,6 +117,692 @@ const PlatformSettings: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const GeneralSettingsTab: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: emailSettings, isLoading, error } = useTicketEmailSettings();
|
||||
const updateMutation = useUpdateTicketEmailSettings();
|
||||
const testImapMutation = useTestImapConnection();
|
||||
const testSmtpMutation = useTestSmtpConnection();
|
||||
const fetchNowMutation = useFetchEmailsNow();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
// IMAP settings
|
||||
imap_host: '',
|
||||
imap_port: 993,
|
||||
imap_use_ssl: true,
|
||||
imap_username: '',
|
||||
imap_password: '',
|
||||
imap_folder: 'INBOX',
|
||||
// SMTP settings
|
||||
smtp_host: '',
|
||||
smtp_port: 587,
|
||||
smtp_use_tls: true,
|
||||
smtp_use_ssl: false,
|
||||
smtp_username: '',
|
||||
smtp_password: '',
|
||||
smtp_from_email: '',
|
||||
smtp_from_name: '',
|
||||
// General settings
|
||||
support_email_address: '',
|
||||
support_email_domain: '',
|
||||
is_enabled: false,
|
||||
delete_after_processing: true,
|
||||
check_interval_seconds: 60,
|
||||
});
|
||||
|
||||
const [showImapPassword, setShowImapPassword] = useState(false);
|
||||
const [showSmtpPassword, setShowSmtpPassword] = useState(false);
|
||||
const [isImapExpanded, setIsImapExpanded] = useState(false);
|
||||
const [isSmtpExpanded, setIsSmtpExpanded] = useState(false);
|
||||
|
||||
// Update form when settings load
|
||||
React.useEffect(() => {
|
||||
if (emailSettings) {
|
||||
setFormData({
|
||||
// IMAP settings
|
||||
imap_host: emailSettings.imap_host || '',
|
||||
imap_port: emailSettings.imap_port || 993,
|
||||
imap_use_ssl: emailSettings.imap_use_ssl ?? true,
|
||||
imap_username: emailSettings.imap_username || '',
|
||||
imap_password: '', // Don't prefill password
|
||||
imap_folder: emailSettings.imap_folder || 'INBOX',
|
||||
// SMTP settings
|
||||
smtp_host: emailSettings.smtp_host || '',
|
||||
smtp_port: emailSettings.smtp_port || 587,
|
||||
smtp_use_tls: emailSettings.smtp_use_tls ?? true,
|
||||
smtp_use_ssl: emailSettings.smtp_use_ssl ?? false,
|
||||
smtp_username: emailSettings.smtp_username || '',
|
||||
smtp_password: '', // Don't prefill password
|
||||
smtp_from_email: emailSettings.smtp_from_email || '',
|
||||
smtp_from_name: emailSettings.smtp_from_name || '',
|
||||
// General settings
|
||||
support_email_address: emailSettings.support_email_address || '',
|
||||
support_email_domain: emailSettings.support_email_domain || '',
|
||||
is_enabled: emailSettings.is_enabled ?? false,
|
||||
delete_after_processing: emailSettings.delete_after_processing ?? true,
|
||||
check_interval_seconds: emailSettings.check_interval_seconds || 60,
|
||||
});
|
||||
}
|
||||
}, [emailSettings]);
|
||||
|
||||
const handleSave = async () => {
|
||||
// Only send passwords if they were changed
|
||||
const dataToSend = { ...formData };
|
||||
if (!dataToSend.imap_password) {
|
||||
delete (dataToSend as any).imap_password;
|
||||
}
|
||||
if (!dataToSend.smtp_password) {
|
||||
delete (dataToSend as any).smtp_password;
|
||||
}
|
||||
await updateMutation.mutateAsync(dataToSend);
|
||||
};
|
||||
|
||||
const handleTestImap = async () => {
|
||||
await testImapMutation.mutateAsync();
|
||||
};
|
||||
|
||||
const handleTestSmtp = async () => {
|
||||
await testSmtpMutation.mutateAsync();
|
||||
};
|
||||
|
||||
const handleFetchNow = async () => {
|
||||
await fetchNowMutation.mutateAsync();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 text-red-700 dark:text-red-400">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<span>Failed to load email settings</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Email Processing Status */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Mail className="w-5 h-5" />
|
||||
{t('platform.settings.emailProcessing', 'Support Email Processing')}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
{emailSettings?.is_enabled ? (
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle className="w-5 h-5 text-yellow-500" />
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">Status</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{emailSettings?.is_enabled ? 'Enabled' : 'Disabled'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
{emailSettings?.is_imap_configured ? (
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle className="w-5 h-5 text-yellow-500" />
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">IMAP (Inbound)</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{emailSettings?.is_imap_configured ? 'Configured' : 'Not configured'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
{emailSettings?.is_smtp_configured ? (
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle className="w-5 h-5 text-yellow-500" />
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">SMTP (Outbound)</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{emailSettings?.is_smtp_configured ? 'Configured' : 'Not configured'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<Clock className="w-5 h-5 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">Last Check</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{emailSettings?.last_check_at
|
||||
? new Date(emailSettings.last_check_at).toLocaleString()
|
||||
: 'Never'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{emailSettings?.last_error && (
|
||||
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">
|
||||
<span className="font-medium">Last Error:</span> {emailSettings.last_error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>Emails processed: <strong>{emailSettings?.emails_processed_count || 0}</strong></span>
|
||||
<span>Check interval: <strong>{emailSettings?.check_interval_seconds || 60}s</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* IMAP Configuration */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsImapExpanded(!isImapExpanded)}
|
||||
className="w-full p-6 flex items-center justify-between text-left"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Server className="w-5 h-5" />
|
||||
{t('platform.settings.imapConfig', 'IMAP Server Configuration (Inbound)')}
|
||||
{emailSettings?.is_imap_configured && (
|
||||
<span className="ml-2 px-2 py-0.5 text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded">
|
||||
Configured
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
{isImapExpanded ? (
|
||||
<ChevronUp className="w-5 h-5 text-gray-500" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isImapExpanded && (
|
||||
<div className="px-6 pb-6 space-y-4">
|
||||
{/* Enable/Disable Toggle */}
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">Enable Email Processing</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Automatically fetch and process incoming support emails
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_enabled}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, is_enabled: e.target.checked }))}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-500 peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Server Settings */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
IMAP Host
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.imap_host}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, imap_host: e.target.value }))}
|
||||
placeholder="mail.talova.net"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Port
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.imap_port}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, imap_port: parseInt(e.target.value) || 993 }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end pb-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.imap_use_ssl}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, imap_use_ssl: e.target.checked }))}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Use SSL</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.imap_username}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, imap_username: e.target.value }))}
|
||||
placeholder="support@yourdomain.com"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showImapPassword ? 'text' : 'password'}
|
||||
value={formData.imap_password}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, imap_password: e.target.value }))}
|
||||
placeholder={emailSettings?.imap_password_masked || 'Enter password'}
|
||||
className="w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowImapPassword(!showImapPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
{showImapPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Folder
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.imap_folder}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, imap_folder: e.target.value }))}
|
||||
placeholder="INBOX"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Support Email Domain
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.support_email_domain}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, support_email_domain: e.target.value }))}
|
||||
placeholder="mail.talova.net"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Domain for reply-to addresses (e.g., support+ticket-123@domain)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test IMAP Button */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleTestImap}
|
||||
disabled={testImapMutation.isPending || !formData.imap_host}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 font-medium rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
|
||||
>
|
||||
{testImapMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Testing IMAP...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Test IMAP Connection
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{testImapMutation.isSuccess && (
|
||||
<div className={`p-3 rounded-lg ${
|
||||
testImapMutation.data?.success
|
||||
? 'bg-green-50 dark:bg-green-900/20'
|
||||
: 'bg-red-50 dark:bg-red-900/20'
|
||||
}`}>
|
||||
<p className={`text-sm flex items-center gap-2 ${
|
||||
testImapMutation.data?.success
|
||||
? 'text-green-700 dark:text-green-400'
|
||||
: 'text-red-700 dark:text-red-400'
|
||||
}`}>
|
||||
{testImapMutation.data?.success ? (
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
)}
|
||||
{testImapMutation.data?.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SMTP Configuration */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSmtpExpanded(!isSmtpExpanded)}
|
||||
className="w-full p-6 flex items-center justify-between text-left"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Send className="w-5 h-5" />
|
||||
{t('platform.settings.smtpConfig', 'SMTP Server Configuration (Outbound)')}
|
||||
{emailSettings?.is_smtp_configured && (
|
||||
<span className="ml-2 px-2 py-0.5 text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded">
|
||||
Configured
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
{isSmtpExpanded ? (
|
||||
<ChevronUp className="w-5 h-5 text-gray-500" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isSmtpExpanded && (
|
||||
<div className="px-6 pb-6 space-y-4">
|
||||
{/* Server Settings */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
SMTP Host
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.smtp_host}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, smtp_host: e.target.value }))}
|
||||
placeholder="smtp.example.com"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Port
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.smtp_port}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, smtp_port: parseInt(e.target.value) || 587 }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end pb-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.smtp_use_tls}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, smtp_use_tls: e.target.checked, smtp_use_ssl: e.target.checked ? false : prev.smtp_use_ssl }))}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">TLS</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-end pb-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.smtp_use_ssl}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, smtp_use_ssl: e.target.checked, smtp_use_tls: e.target.checked ? false : prev.smtp_use_tls }))}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">SSL</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.smtp_username}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, smtp_username: e.target.value }))}
|
||||
placeholder="user@example.com"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSmtpPassword ? 'text' : 'password'}
|
||||
value={formData.smtp_password}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, smtp_password: e.target.value }))}
|
||||
placeholder={emailSettings?.smtp_password_masked || 'Enter password'}
|
||||
className="w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSmtpPassword(!showSmtpPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
{showSmtpPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
From Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.smtp_from_email}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, smtp_from_email: e.target.value }))}
|
||||
placeholder="support@yourdomain.com"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
From Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.smtp_from_name}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, smtp_from_name: e.target.value }))}
|
||||
placeholder="SmoothSchedule Support"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test SMTP Button */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleTestSmtp}
|
||||
disabled={testSmtpMutation.isPending || !formData.smtp_host}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 font-medium rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
|
||||
>
|
||||
{testSmtpMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Testing SMTP...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Test SMTP Connection
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{testSmtpMutation.isSuccess && (
|
||||
<div className={`p-3 rounded-lg ${
|
||||
testSmtpMutation.data?.success
|
||||
? 'bg-green-50 dark:bg-green-900/20'
|
||||
: 'bg-red-50 dark:bg-red-900/20'
|
||||
}`}>
|
||||
<p className={`text-sm flex items-center gap-2 ${
|
||||
testSmtpMutation.data?.success
|
||||
? 'text-green-700 dark:text-green-400'
|
||||
: 'text-red-700 dark:text-red-400'
|
||||
}`}>
|
||||
{testSmtpMutation.data?.success ? (
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
)}
|
||||
{testSmtpMutation.data?.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Processing Settings */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Settings className="w-5 h-5" />
|
||||
{t('platform.settings.processingSettings', 'Processing Settings')}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-4">Email Fetching</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Check Interval (seconds)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="10"
|
||||
max="3600"
|
||||
value={formData.check_interval_seconds}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, check_interval_seconds: parseInt(e.target.value) || 60 }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
How often to check for new emails (10-3600 seconds)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end pb-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.delete_after_processing}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, delete_after_processing: e.target.checked }))}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Delete emails after processing
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save Settings'
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleFetchNow}
|
||||
disabled={fetchNowMutation.isPending || !emailSettings?.is_imap_configured}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 font-medium rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
|
||||
>
|
||||
{fetchNowMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Fetching...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4" />
|
||||
Fetch Now
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status Messages */}
|
||||
{updateMutation.isSuccess && (
|
||||
<div className="p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<p className="text-sm text-green-700 dark:text-green-400 flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Settings saved successfully
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateMutation.isError && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">
|
||||
Failed to save settings. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fetchNowMutation.isSuccess && (
|
||||
<div className="p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<p className="text-sm text-green-700 dark:text-green-400 flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
{fetchNowMutation.data?.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StripeSettingsTab: React.FC = () => {
|
||||
const { data: settings, isLoading, error } = usePlatformSettings();
|
||||
const updateKeysMutation = useUpdateStripeKeys();
|
||||
|
||||
@@ -121,6 +121,16 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
data.permissions = inviteForm.permissions;
|
||||
}
|
||||
|
||||
// Only include limits if at least one is enabled (boolean true or numeric value set)
|
||||
const hasLimits = Object.entries(inviteForm.limits).some(([key, value]) => {
|
||||
if (typeof value === 'boolean') return value === true;
|
||||
if (typeof value === 'number') return true; // numeric limits are meaningful even if 0
|
||||
return false;
|
||||
});
|
||||
if (hasLimits) {
|
||||
data.limits = inviteForm.limits;
|
||||
}
|
||||
|
||||
if (inviteForm.personal_message.trim()) {
|
||||
data.personal_message = inviteForm.personal_message.trim();
|
||||
}
|
||||
@@ -320,24 +330,21 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature Limits (Not Yet Implemented) */}
|
||||
{/* Feature Limits & Capabilities */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Feature Limits & Capabilities
|
||||
</label>
|
||||
<span className="text-xs px-2 py-1 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 rounded-full">
|
||||
Coming Soon
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3 opacity-50">
|
||||
<div className="space-y-3">
|
||||
{/* Video Conferencing */}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.can_add_video_conferencing}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, can_add_video_conferencing: e.target.checked } })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Can add video conferencing to events
|
||||
</label>
|
||||
@@ -347,8 +354,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.max_event_types === null}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 mt-1 cursor-not-allowed"
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, max_event_types: e.target.checked ? null : 10 } })}
|
||||
className="rounded border-gray-300 dark:border-gray-600 mt-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -357,10 +364,11 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
disabled
|
||||
disabled={inviteForm.limits.max_event_types === null}
|
||||
value={inviteForm.limits.max_event_types || ''}
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, max_event_types: e.target.value ? parseInt(e.target.value) : null } })}
|
||||
placeholder="Or set a limit"
|
||||
className="mt-1 w-32 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white cursor-not-allowed"
|
||||
className="mt-1 w-32 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -370,8 +378,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.max_calendars_connected === null}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 mt-1 cursor-not-allowed"
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, max_calendars_connected: e.target.checked ? null : 5 } })}
|
||||
className="rounded border-gray-300 dark:border-gray-600 mt-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -380,10 +388,11 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
disabled
|
||||
disabled={inviteForm.limits.max_calendars_connected === null}
|
||||
value={inviteForm.limits.max_calendars_connected || ''}
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, max_calendars_connected: e.target.value ? parseInt(e.target.value) : null } })}
|
||||
placeholder="Or set a limit"
|
||||
className="mt-1 w-32 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white cursor-not-allowed"
|
||||
className="mt-1 w-32 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -393,8 +402,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.can_connect_to_api}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, can_connect_to_api: e.target.checked } })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Can connect to external APIs
|
||||
</label>
|
||||
@@ -404,8 +413,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.can_book_repeated_events}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, can_book_repeated_events: e.target.checked } })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Can book repeated/recurring events
|
||||
</label>
|
||||
@@ -415,8 +424,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.can_require_2fa}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, can_require_2fa: e.target.checked } })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Can require 2FA for users
|
||||
</label>
|
||||
@@ -426,8 +435,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.can_download_logs}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, can_download_logs: e.target.checked } })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Can download system logs
|
||||
</label>
|
||||
@@ -437,8 +446,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.can_delete_data}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, can_delete_data: e.target.checked } })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Can permanently delete data
|
||||
</label>
|
||||
@@ -448,8 +457,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.can_use_masked_phone_numbers}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, can_use_masked_phone_numbers: e.target.checked } })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Can use masked phone numbers for privacy
|
||||
</label>
|
||||
@@ -459,8 +468,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.can_use_pos}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, can_use_pos: e.target.checked } })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Can use Point of Sale (POS) system
|
||||
</label>
|
||||
@@ -470,8 +479,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.can_use_mobile_app}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, can_use_mobile_app: e.target.checked } })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Can use mobile app
|
||||
</label>
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -12,3 +12,15 @@ REDIS_URL=redis://redis:6379/0
|
||||
# Flower
|
||||
CELERY_FLOWER_USER=aHPdcOatgRsYSHJThjUFyLTrzRXkiVsp
|
||||
CELERY_FLOWER_PASSWORD=mH26NSH3PjskvgwrXplFvX1zFyIjl7O3Tqr9ddpbxd6zjceofepCcITJFVjS9ZwH
|
||||
|
||||
# Twilio (for SMS 2FA)
|
||||
# ------------------------------------------------------------------------------
|
||||
TWILIO_ACCOUNT_SID=AC10d7f7a218404da2219310918ec6f41b
|
||||
TWILIO_AUTH_TOKEN=d6223df9fcd9ebd13cc64a3e20e01b3c
|
||||
TWILIO_PHONE_NUMBER=
|
||||
|
||||
# Stripe (for payments)
|
||||
# ------------------------------------------------------------------------------
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_51SYttT4pb5kWPtNt8n52NRQLyBGbFQ52tnG1O5o11V06m3TPUyIzf6AHOpFNQErBj4m7pOwM6VzltePrdL16IFn0004YqWhRpA
|
||||
STRIPE_SECRET_KEY=sk_test_51SYttT4pb5kWPtNtQUCOMFGHlkMRc88TYAuliEQdZsAb4Rs3mq1OJ4iS1ydQpSPYO3tmnZfm1y1tuMABq7188jsV00VVkfdD6q
|
||||
STRIPE_WEBHOOK_SECRET=whsec_RH4ab9rFuBNdjw8LJ9IimHf1uVukCJIi
|
||||
|
||||
567
smoothschedule/PLAN_EMAIL_TEMPLATES.md
Normal file
@@ -0,0 +1,567 @@
|
||||
# Email Template Generator Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Create an email template system that allows both platform admins and business users to create reusable email templates. Templates can be attached to plugins via a new template variable type that prompts users to select from their email templates.
|
||||
|
||||
## Requirements Summary
|
||||
|
||||
1. **Dual access**: Available in both platform admin and business areas
|
||||
2. **Both formats**: Support text and HTML email templates
|
||||
3. **Visual preview**: Show how the final email will look
|
||||
4. **Plugin integration**: Templates attachable via a new template tag type
|
||||
5. **Separate templates**: Platform and business templates are completely separate
|
||||
6. **Footer enforcement**: Free tier businesses must show "Powered by Smooth Schedule" footer (non-overridable)
|
||||
7. **Editor modes**: Both visual builder AND code editor views
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Backend - EmailTemplate Model
|
||||
|
||||
### 1.1 Create EmailTemplate Model
|
||||
|
||||
**Location**: `schedule/models.py`
|
||||
|
||||
```python
|
||||
class EmailTemplate(models.Model):
|
||||
"""
|
||||
Reusable email template for plugins and automations.
|
||||
|
||||
Supports both text and HTML content with template variable substitution.
|
||||
"""
|
||||
|
||||
class Scope(models.TextChoices):
|
||||
BUSINESS = 'BUSINESS', 'Business' # Tenant-specific
|
||||
PLATFORM = 'PLATFORM', 'Platform' # Platform-wide (shared)
|
||||
|
||||
name = models.CharField(max_length=200)
|
||||
description = models.TextField(blank=True)
|
||||
|
||||
# Email structure
|
||||
subject = models.CharField(max_length=500, help_text="Email subject line - supports template variables")
|
||||
html_content = models.TextField(blank=True, help_text="HTML email body")
|
||||
text_content = models.TextField(blank=True, help_text="Plain text email body")
|
||||
|
||||
# Scope
|
||||
scope = models.CharField(
|
||||
max_length=20,
|
||||
choices=Scope.choices,
|
||||
default=Scope.BUSINESS,
|
||||
)
|
||||
|
||||
# Only for PLATFORM scope templates
|
||||
is_default = models.BooleanField(default=False, help_text="Default template for certain triggers")
|
||||
|
||||
# Metadata
|
||||
created_by = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name='created_email_templates'
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
# Category for organization
|
||||
category = models.CharField(
|
||||
max_length=50,
|
||||
choices=[
|
||||
('APPOINTMENT', 'Appointment'),
|
||||
('REMINDER', 'Reminder'),
|
||||
('CONFIRMATION', 'Confirmation'),
|
||||
('MARKETING', 'Marketing'),
|
||||
('NOTIFICATION', 'Notification'),
|
||||
('REPORT', 'Report'),
|
||||
('OTHER', 'Other'),
|
||||
],
|
||||
default='OTHER'
|
||||
)
|
||||
|
||||
# Preview data for visual preview
|
||||
preview_context = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Sample data for rendering preview"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
indexes = [
|
||||
models.Index(fields=['scope', 'category']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.get_scope_display()})"
|
||||
|
||||
def render(self, context: dict, force_footer: bool = False) -> tuple[str, str, str]:
|
||||
"""
|
||||
Render the template with given context.
|
||||
|
||||
Args:
|
||||
context: Dictionary of template variables
|
||||
force_footer: If True, append "Powered by Smooth Schedule" footer
|
||||
|
||||
Returns:
|
||||
Tuple of (subject, html_content, text_content)
|
||||
"""
|
||||
from .template_parser import TemplateVariableParser
|
||||
|
||||
subject = TemplateVariableParser.replace_insertion_codes(
|
||||
self.subject, context
|
||||
)
|
||||
html = TemplateVariableParser.replace_insertion_codes(
|
||||
self.html_content, context
|
||||
) if self.html_content else ''
|
||||
text = TemplateVariableParser.replace_insertion_codes(
|
||||
self.text_content, context
|
||||
) if self.text_content else ''
|
||||
|
||||
# Append footer for free tier if applicable
|
||||
if force_footer:
|
||||
html = self._append_html_footer(html)
|
||||
text = self._append_text_footer(text)
|
||||
|
||||
return subject, html, text
|
||||
|
||||
def _append_html_footer(self, html: str) -> str:
|
||||
"""Append Powered by Smooth Schedule footer to HTML"""
|
||||
footer = '''
|
||||
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #e5e7eb; text-align: center; color: #9ca3af; font-size: 12px;">
|
||||
<p>
|
||||
Powered by
|
||||
<a href="https://smoothschedule.com" style="color: #6366f1; text-decoration: none; font-weight: 500;">
|
||||
SmoothSchedule
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
'''
|
||||
# Insert before </body> if present, otherwise append
|
||||
if '</body>' in html.lower():
|
||||
import re
|
||||
return re.sub(r'(</body>)', footer + r'\1', html, flags=re.IGNORECASE)
|
||||
return html + footer
|
||||
|
||||
def _append_text_footer(self, text: str) -> str:
|
||||
"""Append Powered by Smooth Schedule footer to plain text"""
|
||||
footer = "\n\n---\nPowered by SmoothSchedule - https://smoothschedule.com"
|
||||
return text + footer
|
||||
```
|
||||
|
||||
### 1.2 Update TemplateVariableParser
|
||||
|
||||
**Location**: `schedule/template_parser.py`
|
||||
|
||||
Add new variable type `email_template`:
|
||||
|
||||
```python
|
||||
# Add to VARIABLE_PATTERN handling
|
||||
# Format: {{PROMPT:variable_name|description||email_template}}
|
||||
|
||||
# When type == 'email_template', the frontend will:
|
||||
# 1. Fetch available email templates from /api/email-templates/
|
||||
# 2. Show a dropdown selector
|
||||
# 3. Store the selected template ID in config_values
|
||||
```
|
||||
|
||||
### 1.3 Create Serializers
|
||||
|
||||
**Location**: `schedule/serializers.py`
|
||||
|
||||
```python
|
||||
class EmailTemplateSerializer(serializers.ModelSerializer):
|
||||
"""Full serializer for CRUD operations"""
|
||||
created_by_name = serializers.CharField(source='created_by.full_name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = EmailTemplate
|
||||
fields = [
|
||||
'id', 'name', 'description', 'subject',
|
||||
'html_content', 'text_content', 'scope',
|
||||
'is_default', 'category', 'preview_context',
|
||||
'created_by', 'created_by_name',
|
||||
'created_at', 'updated_at',
|
||||
]
|
||||
read_only_fields = ['created_at', 'updated_at', 'created_by']
|
||||
|
||||
|
||||
class EmailTemplateListSerializer(serializers.ModelSerializer):
|
||||
"""Lightweight serializer for dropdowns"""
|
||||
|
||||
class Meta:
|
||||
model = EmailTemplate
|
||||
fields = ['id', 'name', 'description', 'category', 'scope']
|
||||
|
||||
|
||||
class EmailTemplatePreviewSerializer(serializers.Serializer):
|
||||
"""Serializer for preview endpoint"""
|
||||
subject = serializers.CharField()
|
||||
html_content = serializers.CharField(allow_blank=True)
|
||||
text_content = serializers.CharField(allow_blank=True)
|
||||
context = serializers.DictField(required=False, default=dict)
|
||||
```
|
||||
|
||||
### 1.4 Create ViewSet
|
||||
|
||||
**Location**: `schedule/views.py`
|
||||
|
||||
```python
|
||||
class EmailTemplateViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for managing email templates.
|
||||
|
||||
- Business users see only BUSINESS scope templates
|
||||
- Platform users can also see/create PLATFORM scope templates
|
||||
"""
|
||||
serializer_class = EmailTemplateSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
|
||||
# Platform users see all templates
|
||||
if user.is_platform_user:
|
||||
scope = self.request.query_params.get('scope')
|
||||
if scope:
|
||||
return EmailTemplate.objects.filter(scope=scope.upper())
|
||||
return EmailTemplate.objects.all()
|
||||
|
||||
# Business users only see BUSINESS scope templates
|
||||
return EmailTemplate.objects.filter(scope=EmailTemplate.Scope.BUSINESS)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(created_by=self.request.user)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def preview(self, request):
|
||||
"""Render a preview of the template with sample data"""
|
||||
serializer = EmailTemplatePreviewSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
from .template_parser import TemplateVariableParser
|
||||
|
||||
context = serializer.validated_data.get('context', {})
|
||||
subject = serializer.validated_data['subject']
|
||||
html = serializer.validated_data.get('html_content', '')
|
||||
text = serializer.validated_data.get('text_content', '')
|
||||
|
||||
# Add default sample values
|
||||
default_context = {
|
||||
'BUSINESS_NAME': 'Demo Business',
|
||||
'BUSINESS_EMAIL': 'contact@demo.com',
|
||||
'BUSINESS_PHONE': '(555) 123-4567',
|
||||
'CUSTOMER_NAME': 'John Doe',
|
||||
'CUSTOMER_EMAIL': 'john@example.com',
|
||||
'APPOINTMENT_TIME': 'Monday, January 15, 2025 at 2:00 PM',
|
||||
'APPOINTMENT_DATE': 'January 15, 2025',
|
||||
'APPOINTMENT_SERVICE': 'Consultation',
|
||||
'TODAY': datetime.now().strftime('%B %d, %Y'),
|
||||
'NOW': datetime.now().strftime('%B %d, %Y at %I:%M %p'),
|
||||
}
|
||||
default_context.update(context)
|
||||
|
||||
rendered_subject = TemplateVariableParser.replace_insertion_codes(subject, default_context)
|
||||
rendered_html = TemplateVariableParser.replace_insertion_codes(html, default_context)
|
||||
rendered_text = TemplateVariableParser.replace_insertion_codes(text, default_context)
|
||||
|
||||
# Check if free tier - append footer
|
||||
force_footer = False
|
||||
if not request.user.is_platform_user:
|
||||
from django.db import connection
|
||||
if hasattr(connection, 'tenant') and connection.tenant.subscription_tier == 'FREE':
|
||||
force_footer = True
|
||||
|
||||
if force_footer:
|
||||
rendered_html = EmailTemplate._append_html_footer(None, rendered_html)
|
||||
rendered_text = EmailTemplate._append_text_footer(None, rendered_text)
|
||||
|
||||
return Response({
|
||||
'subject': rendered_subject,
|
||||
'html_content': rendered_html,
|
||||
'text_content': rendered_text,
|
||||
'force_footer': force_footer,
|
||||
})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def duplicate(self, request, pk=None):
|
||||
"""Create a copy of an existing template"""
|
||||
template = self.get_object()
|
||||
new_template = EmailTemplate.objects.create(
|
||||
name=f"{template.name} (Copy)",
|
||||
description=template.description,
|
||||
subject=template.subject,
|
||||
html_content=template.html_content,
|
||||
text_content=template.text_content,
|
||||
scope=template.scope,
|
||||
category=template.category,
|
||||
preview_context=template.preview_context,
|
||||
created_by=request.user,
|
||||
)
|
||||
return Response(EmailTemplateSerializer(new_template).data, status=201)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Plugin Integration
|
||||
|
||||
### 2.1 Update Template Parser for email_template Type
|
||||
|
||||
**Location**: `schedule/template_parser.py`
|
||||
|
||||
```python
|
||||
# In extract_variables method, when type == 'email_template':
|
||||
# Return special metadata to indicate dropdown
|
||||
|
||||
@classmethod
|
||||
def _infer_type(cls, var_name: str, description: str) -> str:
|
||||
# ... existing logic ...
|
||||
|
||||
# Check for explicit email_template type
|
||||
# This is handled in the main extraction logic
|
||||
pass
|
||||
```
|
||||
|
||||
### 2.2 Update Plugin Execution to Handle Email Templates
|
||||
|
||||
**Location**: `schedule/tasks.py`
|
||||
|
||||
```python
|
||||
def execute_plugin_with_email(plugin_code: str, config_values: dict, context: dict):
|
||||
"""
|
||||
Execute plugin code with email template support.
|
||||
|
||||
When config_values contains an email_template_id, load and render it.
|
||||
"""
|
||||
# Check for email template references in config
|
||||
for key, value in config_values.items():
|
||||
if key.endswith('_email_template') and isinstance(value, int):
|
||||
# Load the email template
|
||||
try:
|
||||
template = EmailTemplate.objects.get(id=value)
|
||||
# Render and make available in context
|
||||
subject, html, text = template.render(context)
|
||||
context[f'{key}_subject'] = subject
|
||||
context[f'{key}_html'] = html
|
||||
context[f'{key}_text'] = text
|
||||
except EmailTemplate.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Continue with normal execution
|
||||
# ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Frontend - Email Template Editor
|
||||
|
||||
### 3.1 Create EmailTemplateEditor Component
|
||||
|
||||
**Location**: `frontend/src/pages/EmailTemplates.tsx`
|
||||
|
||||
Main page with:
|
||||
- List of templates with search/filter
|
||||
- Create/Edit modal with dual-mode editor
|
||||
- Preview panel
|
||||
|
||||
### 3.2 Create EmailTemplateForm Component
|
||||
|
||||
**Location**: `frontend/src/components/EmailTemplateForm.tsx`
|
||||
|
||||
Features:
|
||||
1. **Subject line editor** - Simple text input with variable insertion
|
||||
2. **Content editor** - Tabbed interface:
|
||||
- **Visual mode**: WYSIWYG editor (TipTap or similar)
|
||||
- **Code mode**: Monaco/CodeMirror for raw HTML
|
||||
3. **Plain text editor** - Textarea with variable insertion buttons
|
||||
4. **Preview panel** - Live rendering of HTML email
|
||||
|
||||
### 3.3 Variable Insertion Toolbar
|
||||
|
||||
Available variables shown as clickable chips:
|
||||
- `{{BUSINESS_NAME}}`
|
||||
- `{{BUSINESS_EMAIL}}`
|
||||
- `{{CUSTOMER_NAME}}`
|
||||
- `{{APPOINTMENT_TIME}}`
|
||||
- etc.
|
||||
|
||||
### 3.4 Preview Component
|
||||
|
||||
**Location**: `frontend/src/components/EmailPreview.tsx`
|
||||
|
||||
- Desktop/mobile toggle
|
||||
- Light/dark mode preview
|
||||
- Sample data editor
|
||||
- Footer preview (shown for free tier)
|
||||
|
||||
### 3.5 Update Plugin Config Form
|
||||
|
||||
**Location**: `frontend/src/pages/MyPlugins.tsx`
|
||||
|
||||
When `variable.type === 'email_template'`:
|
||||
```tsx
|
||||
<EmailTemplateSelector
|
||||
value={configValues[key]}
|
||||
onChange={(templateId) => setConfigValues({ ...configValues, [key]: templateId })}
|
||||
scope="BUSINESS" // or "PLATFORM" for platform admin
|
||||
/>
|
||||
```
|
||||
|
||||
### 3.6 EmailTemplateSelector Component
|
||||
|
||||
**Location**: `frontend/src/components/EmailTemplateSelector.tsx`
|
||||
|
||||
- Dropdown showing available templates
|
||||
- Category filtering
|
||||
- Quick preview on hover
|
||||
- "Create New" link
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Footer Enforcement
|
||||
|
||||
### 4.1 Backend Enforcement
|
||||
|
||||
In `EmailTemplate.render()` and preview endpoint:
|
||||
- Check tenant subscription tier
|
||||
- If `FREE`, always append footer regardless of template content
|
||||
- Footer cannot be removed via template editing
|
||||
|
||||
### 4.2 Frontend Enforcement
|
||||
|
||||
In EmailTemplateForm:
|
||||
- Show permanent footer preview for free tier
|
||||
- Disable footer editing for free tier
|
||||
- Show upgrade prompt: "Upgrade to remove footer"
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Platform Admin Features
|
||||
|
||||
### 5.1 Platform Email Templates Page
|
||||
|
||||
**Location**: `frontend/src/pages/platform/PlatformEmailTemplates.tsx`
|
||||
|
||||
- Create/manage PLATFORM scope templates
|
||||
- Set default templates for system events
|
||||
- Preview with tenant context
|
||||
|
||||
### 5.2 Default Templates
|
||||
|
||||
Create seed data for common templates:
|
||||
- Tenant invitation email (already exists)
|
||||
- Appointment reminder
|
||||
- Appointment confirmation
|
||||
- Password reset
|
||||
- Welcome email
|
||||
|
||||
---
|
||||
|
||||
## Database Migrations
|
||||
|
||||
```python
|
||||
# schedule/migrations/XXXX_email_template.py
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('schedule', 'previous_migration'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='EmailTemplate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True)),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('subject', models.CharField(max_length=500)),
|
||||
('html_content', models.TextField(blank=True)),
|
||||
('text_content', models.TextField(blank=True)),
|
||||
('scope', models.CharField(max_length=20, choices=[
|
||||
('BUSINESS', 'Business'),
|
||||
('PLATFORM', 'Platform'),
|
||||
], default='BUSINESS')),
|
||||
('is_default', models.BooleanField(default=False)),
|
||||
('category', models.CharField(max_length=50, choices=[
|
||||
('APPOINTMENT', 'Appointment'),
|
||||
('REMINDER', 'Reminder'),
|
||||
('CONFIRMATION', 'Confirmation'),
|
||||
('MARKETING', 'Marketing'),
|
||||
('NOTIFICATION', 'Notification'),
|
||||
('REPORT', 'Report'),
|
||||
('OTHER', 'Other'),
|
||||
], default='OTHER')),
|
||||
('preview_context', models.JSONField(default=dict, blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('created_by', models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='created_email_templates',
|
||||
to='users.user'
|
||||
)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='emailtemplate',
|
||||
index=models.Index(fields=['scope', 'category'], name='schedule_em_scope_123456_idx'),
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/email-templates/` | List templates (filtered by scope) |
|
||||
| POST | `/api/email-templates/` | Create template |
|
||||
| GET | `/api/email-templates/{id}/` | Get template details |
|
||||
| PATCH | `/api/email-templates/{id}/` | Update template |
|
||||
| DELETE | `/api/email-templates/{id}/` | Delete template |
|
||||
| POST | `/api/email-templates/preview/` | Render preview |
|
||||
| POST | `/api/email-templates/{id}/duplicate/` | Duplicate template |
|
||||
|
||||
---
|
||||
|
||||
## Frontend Routes
|
||||
|
||||
| Route | Component | Description |
|
||||
|-------|-----------|-------------|
|
||||
| `/email-templates` | EmailTemplates | Business email templates |
|
||||
| `/email-templates/create` | EmailTemplateForm | Create new template |
|
||||
| `/email-templates/:id/edit` | EmailTemplateForm | Edit existing template |
|
||||
| `/platform/email-templates` | PlatformEmailTemplates | Platform templates |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Backend Model & Migration** - EmailTemplate model
|
||||
2. **Backend API** - Serializers, ViewSet, URLs
|
||||
3. **Frontend List Page** - Basic CRUD
|
||||
4. **Frontend Editor** - Dual-mode editor
|
||||
5. **Preview Component** - Live preview
|
||||
6. **Plugin Integration** - email_template variable type
|
||||
7. **Footer Enforcement** - Free tier logic
|
||||
8. **Platform Admin** - Platform templates page
|
||||
9. **Seed Data** - Default templates
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Create business email template
|
||||
- [ ] Create platform email template (as superuser)
|
||||
- [ ] Preview template with sample data
|
||||
- [ ] Verify footer appears for free tier
|
||||
- [ ] Verify footer hidden for paid tiers
|
||||
- [ ] Verify footer appears in preview for free tier
|
||||
- [ ] Edit template in visual mode
|
||||
- [ ] Edit template in code mode
|
||||
- [ ] Use template in plugin configuration
|
||||
- [ ] Send actual email using template
|
||||
- [ ] Duplicate template
|
||||
- [ ] Delete template
|
||||
@@ -99,6 +99,80 @@ Global registry for managing plugins.
|
||||
- `list_all()` - List all plugins with metadata
|
||||
- `list_by_category()` - Group plugins by category
|
||||
|
||||
#### Template Variables (`schedule/template_parser.py`)
|
||||
|
||||
Template variables allow plugins to define configurable fields that users can fill in via the UI.
|
||||
|
||||
**Variable Format:**
|
||||
```
|
||||
{{PROMPT:variable_name|description}}
|
||||
{{PROMPT:variable_name|description|default_value}}
|
||||
{{PROMPT:variable_name|description|default_value|type}}
|
||||
{{PROMPT:variable_name|description||type}} (no default, explicit type)
|
||||
```
|
||||
|
||||
**Supported Types:**
|
||||
|
||||
| Type | Description | UI Component |
|
||||
|------|-------------|--------------|
|
||||
| `text` | Single-line text input | Text input |
|
||||
| `textarea` | Multi-line text input | Textarea |
|
||||
| `email` | Email address | Email input with validation |
|
||||
| `number` | Numeric value | Number input |
|
||||
| `url` | URL/webhook endpoint | URL input with validation |
|
||||
| `email_template` | Email template selector | Dropdown of email templates |
|
||||
|
||||
**Type Inference:**
|
||||
If no explicit type is provided, the parser infers type from the variable name:
|
||||
- Names containing `email` → `email` type
|
||||
- Names containing `count`, `days`, `hours`, `amount` → `number` type
|
||||
- Names containing `url`, `webhook`, `endpoint` → `url` type
|
||||
- Names containing `message`, `body`, `content` → `textarea` type
|
||||
- Default → `text` type
|
||||
|
||||
**Example - Email Template Variable:**
|
||||
```python
|
||||
# In your plugin script, reference an email template:
|
||||
template_id = {{PROMPT:confirmation_template|Email template for confirmations||email_template}}
|
||||
|
||||
# The UI will show a dropdown of all available email templates
|
||||
# The user selects a template, and the template ID is stored in the config
|
||||
```
|
||||
|
||||
**Using Email Templates in Plugins:**
|
||||
```python
|
||||
from schedule.models import EmailTemplate
|
||||
|
||||
class MyNotificationPlugin(BasePlugin):
|
||||
def execute(self, context):
|
||||
template_id = self.config.get('confirmation_template')
|
||||
|
||||
if template_id:
|
||||
template = EmailTemplate.objects.get(id=template_id)
|
||||
subject, html, text = template.render({
|
||||
'BUSINESS_NAME': context['business'].name,
|
||||
'CUSTOMER_NAME': customer.name,
|
||||
# ... other variables
|
||||
})
|
||||
|
||||
# Send the email using the rendered template
|
||||
send_email(recipient, subject, html, text)
|
||||
```
|
||||
|
||||
**Insertion Codes (for use within templates):**
|
||||
|
||||
These codes are replaced at runtime with actual values:
|
||||
- `{{BUSINESS_NAME}}` - Business name
|
||||
- `{{BUSINESS_EMAIL}}` - Business contact email
|
||||
- `{{BUSINESS_PHONE}}` - Business phone number
|
||||
- `{{CUSTOMER_NAME}}` - Customer's name
|
||||
- `{{CUSTOMER_EMAIL}}` - Customer's email
|
||||
- `{{APPOINTMENT_TIME}}` - Appointment date/time
|
||||
- `{{APPOINTMENT_DATE}}` - Appointment date only
|
||||
- `{{APPOINTMENT_SERVICE}}` - Service name
|
||||
- `{{TODAY}}` - Today's date
|
||||
- `{{NOW}}` - Current date and time
|
||||
|
||||
### 3. Celery Tasks (`schedule/tasks.py`)
|
||||
|
||||
#### execute_scheduled_task(scheduled_task_id)
|
||||
@@ -298,6 +372,11 @@ class MyCustomPlugin(BasePlugin):
|
||||
'default': 100,
|
||||
'description': 'Processing threshold',
|
||||
},
|
||||
'notification_template': {
|
||||
'type': 'email_template',
|
||||
'required': False,
|
||||
'description': 'Email template to use for notifications',
|
||||
},
|
||||
}
|
||||
|
||||
def execute(self, context):
|
||||
|
||||
@@ -333,3 +333,23 @@ CHANNEL_LAYERS = {
|
||||
|
||||
# Your stuff...
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
# Twilio (for SMS 2FA)
|
||||
# ------------------------------------------------------------------------------
|
||||
TWILIO_ACCOUNT_SID = env("TWILIO_ACCOUNT_SID", default="")
|
||||
TWILIO_AUTH_TOKEN = env("TWILIO_AUTH_TOKEN", default="")
|
||||
TWILIO_PHONE_NUMBER = env("TWILIO_PHONE_NUMBER", default="")
|
||||
|
||||
# Stripe (for payments)
|
||||
# ------------------------------------------------------------------------------
|
||||
STRIPE_PUBLISHABLE_KEY = env("STRIPE_PUBLISHABLE_KEY", default="")
|
||||
STRIPE_SECRET_KEY = env("STRIPE_SECRET_KEY", default="")
|
||||
STRIPE_WEBHOOK_SECRET = env("STRIPE_WEBHOOK_SECRET", default="")
|
||||
|
||||
# dj-stripe configuration
|
||||
STRIPE_LIVE_SECRET_KEY = env("STRIPE_SECRET_KEY", default="")
|
||||
STRIPE_TEST_SECRET_KEY = env("STRIPE_SECRET_KEY", default="")
|
||||
STRIPE_LIVE_MODE = env.bool("STRIPE_LIVE_MODE", default=False)
|
||||
DJSTRIPE_WEBHOOK_SECRET = env("STRIPE_WEBHOOK_SECRET", default="")
|
||||
DJSTRIPE_USE_NATIVE_JSONFIELD = True
|
||||
DJSTRIPE_FOREIGN_KEY_TO_FIELD = "id"
|
||||
|
||||
@@ -43,6 +43,7 @@ SHARED_APPS = [
|
||||
'crispy_forms',
|
||||
'crispy_bootstrap5',
|
||||
'csp',
|
||||
'djstripe', # Stripe integration
|
||||
'tickets', # Ticket system - shared for platform support access
|
||||
'smoothschedule.public_api', # Public API v1 for third-party integrations
|
||||
]
|
||||
|
||||
@@ -11,11 +11,17 @@ from drf_spectacular.views import SpectacularSwaggerView
|
||||
from rest_framework.authtoken.views import obtain_auth_token
|
||||
|
||||
from smoothschedule.users.api_views import (
|
||||
current_user_view, logout_view, send_verification_email, verify_email,
|
||||
login_view, current_user_view, logout_view, send_verification_email, verify_email,
|
||||
hijack_acquire_view, hijack_release_view,
|
||||
staff_invitations_view, cancel_invitation_view, resend_invitation_view,
|
||||
invitation_details_view, accept_invitation_view, decline_invitation_view
|
||||
)
|
||||
from smoothschedule.users.mfa_api_views import (
|
||||
mfa_status, send_phone_verification, verify_phone, enable_sms_mfa,
|
||||
setup_totp, verify_totp_setup, generate_backup_codes, backup_codes_status,
|
||||
disable_mfa, mfa_login_send_code, mfa_login_verify,
|
||||
list_trusted_devices, revoke_trusted_device, revoke_all_trusted_devices
|
||||
)
|
||||
from schedule.api_views import (
|
||||
current_business_view, update_business_view,
|
||||
oauth_settings_view, oauth_credentials_view,
|
||||
@@ -40,6 +46,9 @@ urlpatterns = [
|
||||
|
||||
# API URLS
|
||||
urlpatterns += [
|
||||
# Stripe Webhooks (dj-stripe built-in handler)
|
||||
# This is the URL you register with Stripe: https://yourdomain.com/api/stripe/webhook/
|
||||
path("api/stripe/", include("djstripe.urls", namespace="djstripe")),
|
||||
# Public API v1 (for third-party integrations)
|
||||
path("api/v1/", include("smoothschedule.public_api.urls", namespace="public_api")),
|
||||
# Schedule API (internal)
|
||||
@@ -54,6 +63,7 @@ urlpatterns += [
|
||||
path("api/platform/", include("platform_admin.urls", namespace="platform")),
|
||||
# Auth API
|
||||
path("api/auth-token/", csrf_exempt(obtain_auth_token), name="obtain_auth_token"),
|
||||
path("api/auth/login/", login_view, name="login"),
|
||||
path("api/auth/me/", current_user_view, name="current_user"),
|
||||
path("api/auth/logout/", logout_view, name="logout"),
|
||||
path("api/auth/email/verify/send/", send_verification_email, name="send_verification_email"),
|
||||
@@ -82,6 +92,21 @@ urlpatterns += [
|
||||
path("api/sandbox/status/", sandbox_status_view, name="sandbox_status"),
|
||||
path("api/sandbox/toggle/", sandbox_toggle_view, name="sandbox_toggle"),
|
||||
path("api/sandbox/reset/", sandbox_reset_view, name="sandbox_reset"),
|
||||
# MFA (Two-Factor Authentication) API
|
||||
path("api/auth/mfa/status/", mfa_status, name="mfa_status"),
|
||||
path("api/auth/mfa/phone/send/", send_phone_verification, name="mfa_phone_send"),
|
||||
path("api/auth/mfa/phone/verify/", verify_phone, name="mfa_phone_verify"),
|
||||
path("api/auth/mfa/sms/enable/", enable_sms_mfa, name="mfa_sms_enable"),
|
||||
path("api/auth/mfa/totp/setup/", setup_totp, name="mfa_totp_setup"),
|
||||
path("api/auth/mfa/totp/verify/", verify_totp_setup, name="mfa_totp_verify"),
|
||||
path("api/auth/mfa/backup-codes/", generate_backup_codes, name="mfa_backup_codes"),
|
||||
path("api/auth/mfa/backup-codes/status/", backup_codes_status, name="mfa_backup_codes_status"),
|
||||
path("api/auth/mfa/disable/", disable_mfa, name="mfa_disable"),
|
||||
path("api/auth/mfa/login/send/", mfa_login_send_code, name="mfa_login_send"),
|
||||
path("api/auth/mfa/login/verify/", mfa_login_verify, name="mfa_login_verify"),
|
||||
path("api/auth/mfa/devices/", list_trusted_devices, name="mfa_devices_list"),
|
||||
path("api/auth/mfa/devices/<int:device_id>/", revoke_trusted_device, name="mfa_device_revoke"),
|
||||
path("api/auth/mfa/devices/revoke-all/", revoke_all_trusted_devices, name="mfa_devices_revoke_all"),
|
||||
# API Docs
|
||||
path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"),
|
||||
path(
|
||||
|
||||
68
smoothschedule/core/migrations/0009_add_feature_limits.py
Normal file
@@ -0,0 +1,68 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-29 18:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0008_add_sandbox_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='can_add_video_conferencing',
|
||||
field=models.BooleanField(default=False, help_text='Whether this business can add video conferencing to events'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='can_book_repeated_events',
|
||||
field=models.BooleanField(default=True, help_text='Whether this business can book repeated/recurring events'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='can_connect_to_api',
|
||||
field=models.BooleanField(default=False, help_text='Whether this business can connect to external APIs'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='can_delete_data',
|
||||
field=models.BooleanField(default=False, help_text='Whether this business can permanently delete data'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='can_download_logs',
|
||||
field=models.BooleanField(default=False, help_text='Whether this business can download system logs'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='can_require_2fa',
|
||||
field=models.BooleanField(default=False, help_text='Whether this business can require 2FA for users'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='can_use_masked_phone_numbers',
|
||||
field=models.BooleanField(default=False, help_text='Whether this business can use masked phone numbers for privacy'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='can_use_mobile_app',
|
||||
field=models.BooleanField(default=False, help_text='Whether this business can use the mobile app'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='can_use_pos',
|
||||
field=models.BooleanField(default=False, help_text='Whether this business can use Point of Sale (POS) system'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='max_calendars_connected',
|
||||
field=models.IntegerField(blank=True, help_text='Maximum number of external calendars connected (null = unlimited)', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='max_event_types',
|
||||
field=models.IntegerField(blank=True, help_text='Maximum number of event types (null = unlimited)', null=True),
|
||||
),
|
||||
]
|
||||
@@ -120,6 +120,54 @@ class Tenant(TenantMixin):
|
||||
help_text="Whether this business can access the API for integrations"
|
||||
)
|
||||
|
||||
# Feature Limits & Capabilities (set by platform admins via tenant invitation)
|
||||
can_add_video_conferencing = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this business can add video conferencing to events"
|
||||
)
|
||||
max_event_types = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Maximum number of event types (null = unlimited)"
|
||||
)
|
||||
max_calendars_connected = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Maximum number of external calendars connected (null = unlimited)"
|
||||
)
|
||||
can_connect_to_api = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this business can connect to external APIs"
|
||||
)
|
||||
can_book_repeated_events = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether this business can book repeated/recurring events"
|
||||
)
|
||||
can_require_2fa = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this business can require 2FA for users"
|
||||
)
|
||||
can_download_logs = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this business can download system logs"
|
||||
)
|
||||
can_delete_data = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this business can permanently delete data"
|
||||
)
|
||||
can_use_masked_phone_numbers = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this business can use masked phone numbers for privacy"
|
||||
)
|
||||
can_use_pos = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this business can use Point of Sale (POS) system"
|
||||
)
|
||||
can_use_mobile_app = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this business can use the mobile app"
|
||||
)
|
||||
|
||||
# Onboarding tracking
|
||||
initial_setup_complete = models.BooleanField(
|
||||
default=False,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-29 18:50
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('platform_admin', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='tenantinvitation',
|
||||
name='limits',
|
||||
field=models.JSONField(blank=True, default=dict, help_text='Feature limits and capabilities to grant'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-29 21:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('platform_admin', '0002_add_limits_field'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PlatformSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('stripe_secret_key', models.CharField(blank=True, default='', help_text='Stripe secret key (overrides env if set)', max_length=255)),
|
||||
('stripe_publishable_key', models.CharField(blank=True, default='', help_text='Stripe publishable key (overrides env if set)', max_length=255)),
|
||||
('stripe_webhook_secret', models.CharField(blank=True, default='', help_text='Stripe webhook secret (overrides env if set)', max_length=255)),
|
||||
('stripe_account_id', models.CharField(blank=True, default='', help_text='Stripe account ID (set after validation)', max_length=255)),
|
||||
('stripe_account_name', models.CharField(blank=True, default='', help_text='Stripe account name (set after validation)', max_length=255)),
|
||||
('stripe_keys_validated_at', models.DateTimeField(blank=True, help_text='When the Stripe keys were last validated', null=True)),
|
||||
('stripe_validation_error', models.TextField(blank=True, default='', help_text='Last validation error, if any')),
|
||||
('oauth_settings', models.JSONField(blank=True, default=dict, help_text='OAuth provider settings (Google, Apple, etc.)')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Platform Settings',
|
||||
'verbose_name_plural': 'Platform Settings',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-29 21:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('platform_admin', '0003_add_platform_settings'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SubscriptionPlan',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('description', models.TextField(blank=True, default='')),
|
||||
('plan_type', models.CharField(choices=[('base', 'Base Plan'), ('addon', 'Add-on')], default='base', max_length=10)),
|
||||
('stripe_product_id', models.CharField(blank=True, default='', help_text='Stripe Product ID', max_length=255)),
|
||||
('stripe_price_id', models.CharField(blank=True, default='', help_text='Stripe Price ID (for monthly billing)', max_length=255)),
|
||||
('price_monthly', models.DecimalField(blank=True, decimal_places=2, help_text='Monthly price in dollars', max_digits=10, null=True)),
|
||||
('price_yearly', models.DecimalField(blank=True, decimal_places=2, help_text='Yearly price in dollars', max_digits=10, null=True)),
|
||||
('business_tier', models.CharField(choices=[('FREE', 'Free'), ('STARTER', 'Starter'), ('PROFESSIONAL', 'Professional'), ('ENTERPRISE', 'Enterprise')], default='PROFESSIONAL', max_length=50)),
|
||||
('features', models.JSONField(blank=True, default=list, help_text='List of feature descriptions')),
|
||||
('transaction_fee_percent', models.DecimalField(decimal_places=2, default=2.9, help_text='Transaction fee percentage', max_digits=5)),
|
||||
('transaction_fee_fixed', models.DecimalField(decimal_places=2, default=0.3, help_text='Fixed transaction fee in dollars', max_digits=5)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('is_public', models.BooleanField(default=True, help_text='Whether this plan is visible on public pricing page')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['price_monthly', 'name'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -7,6 +7,248 @@ from datetime import timedelta
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.core.cache import cache
|
||||
|
||||
|
||||
class PlatformSettings(models.Model):
|
||||
"""
|
||||
Singleton model for platform-wide settings.
|
||||
Settings like Stripe keys can be overridden via environment variables.
|
||||
"""
|
||||
|
||||
# Stripe settings (can be overridden or set via admin)
|
||||
stripe_secret_key = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default='',
|
||||
help_text="Stripe secret key (overrides env if set)"
|
||||
)
|
||||
stripe_publishable_key = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default='',
|
||||
help_text="Stripe publishable key (overrides env if set)"
|
||||
)
|
||||
stripe_webhook_secret = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default='',
|
||||
help_text="Stripe webhook secret (overrides env if set)"
|
||||
)
|
||||
|
||||
# Stripe validation info
|
||||
stripe_account_id = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default='',
|
||||
help_text="Stripe account ID (set after validation)"
|
||||
)
|
||||
stripe_account_name = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default='',
|
||||
help_text="Stripe account name (set after validation)"
|
||||
)
|
||||
stripe_keys_validated_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="When the Stripe keys were last validated"
|
||||
)
|
||||
stripe_validation_error = models.TextField(
|
||||
blank=True,
|
||||
default='',
|
||||
help_text="Last validation error, if any"
|
||||
)
|
||||
|
||||
# OAuth settings - stored as JSON for flexibility
|
||||
oauth_settings = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="OAuth provider settings (Google, Apple, etc.)"
|
||||
)
|
||||
# Example oauth_settings structure:
|
||||
# {
|
||||
# "allow_registration": true,
|
||||
# "google": {"enabled": true, "client_id": "...", "client_secret": "..."},
|
||||
# "apple": {"enabled": false, "client_id": "", ...},
|
||||
# ...
|
||||
# }
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'platform_admin'
|
||||
verbose_name = 'Platform Settings'
|
||||
verbose_name_plural = 'Platform Settings'
|
||||
|
||||
def __str__(self):
|
||||
return "Platform Settings"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Ensure only one instance exists
|
||||
self.pk = 1
|
||||
super().save(*args, **kwargs)
|
||||
# Clear cache
|
||||
cache.delete('platform_settings')
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
# Prevent deletion
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls):
|
||||
"""Get or create the singleton instance"""
|
||||
# Try cache first
|
||||
cached = cache.get('platform_settings')
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
instance, _ = cls.objects.get_or_create(pk=1)
|
||||
cache.set('platform_settings', instance, timeout=300) # 5 min cache
|
||||
return instance
|
||||
|
||||
def get_stripe_secret_key(self):
|
||||
"""Get Stripe secret key (database or env)"""
|
||||
from django.conf import settings
|
||||
if self.stripe_secret_key:
|
||||
return self.stripe_secret_key
|
||||
return getattr(settings, 'STRIPE_SECRET_KEY', '')
|
||||
|
||||
def get_stripe_publishable_key(self):
|
||||
"""Get Stripe publishable key (database or env)"""
|
||||
from django.conf import settings
|
||||
if self.stripe_publishable_key:
|
||||
return self.stripe_publishable_key
|
||||
return getattr(settings, 'STRIPE_PUBLISHABLE_KEY', '')
|
||||
|
||||
def get_stripe_webhook_secret(self):
|
||||
"""Get Stripe webhook secret (database or env)"""
|
||||
from django.conf import settings
|
||||
if self.stripe_webhook_secret:
|
||||
return self.stripe_webhook_secret
|
||||
return getattr(settings, 'STRIPE_WEBHOOK_SECRET', '')
|
||||
|
||||
def has_stripe_keys(self):
|
||||
"""Check if Stripe keys are configured"""
|
||||
return bool(self.get_stripe_secret_key() and self.get_stripe_publishable_key())
|
||||
|
||||
def stripe_keys_from_env(self):
|
||||
"""Check if Stripe keys come from environment"""
|
||||
from django.conf import settings
|
||||
return (
|
||||
not self.stripe_secret_key and
|
||||
bool(getattr(settings, 'STRIPE_SECRET_KEY', ''))
|
||||
)
|
||||
|
||||
def mask_key(self, key, visible_chars=8):
|
||||
"""Mask a key, showing only first/last chars"""
|
||||
if not key:
|
||||
return ''
|
||||
if len(key) <= visible_chars * 2:
|
||||
return '*' * len(key)
|
||||
return f"{key[:visible_chars]}...{key[-4:]}"
|
||||
|
||||
|
||||
class SubscriptionPlan(models.Model):
|
||||
"""
|
||||
Subscription plans that can be assigned to tenants.
|
||||
Links to Stripe products/prices for billing.
|
||||
"""
|
||||
|
||||
class PlanType(models.TextChoices):
|
||||
BASE = 'base', _('Base Plan')
|
||||
ADDON = 'addon', _('Add-on')
|
||||
|
||||
name = models.CharField(max_length=100)
|
||||
description = models.TextField(blank=True, default='')
|
||||
plan_type = models.CharField(
|
||||
max_length=10,
|
||||
choices=PlanType.choices,
|
||||
default=PlanType.BASE
|
||||
)
|
||||
|
||||
# Stripe integration
|
||||
stripe_product_id = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default='',
|
||||
help_text="Stripe Product ID"
|
||||
)
|
||||
stripe_price_id = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default='',
|
||||
help_text="Stripe Price ID (for monthly billing)"
|
||||
)
|
||||
|
||||
# Pricing
|
||||
price_monthly = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Monthly price in dollars"
|
||||
)
|
||||
price_yearly = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Yearly price in dollars"
|
||||
)
|
||||
|
||||
# Business tier this plan corresponds to
|
||||
business_tier = models.CharField(
|
||||
max_length=50,
|
||||
choices=[
|
||||
('FREE', 'Free'),
|
||||
('STARTER', 'Starter'),
|
||||
('PROFESSIONAL', 'Professional'),
|
||||
('ENTERPRISE', 'Enterprise'),
|
||||
],
|
||||
default='PROFESSIONAL'
|
||||
)
|
||||
|
||||
# Features included (stored as JSON array of strings)
|
||||
features = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
help_text="List of feature descriptions"
|
||||
)
|
||||
|
||||
# Transaction fees for payment processing
|
||||
transaction_fee_percent = models.DecimalField(
|
||||
max_digits=5,
|
||||
decimal_places=2,
|
||||
default=2.9,
|
||||
help_text="Transaction fee percentage"
|
||||
)
|
||||
transaction_fee_fixed = models.DecimalField(
|
||||
max_digits=5,
|
||||
decimal_places=2,
|
||||
default=0.30,
|
||||
help_text="Fixed transaction fee in dollars"
|
||||
)
|
||||
|
||||
# Visibility
|
||||
is_active = models.BooleanField(default=True)
|
||||
is_public = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether this plan is visible on public pricing page"
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'platform_admin'
|
||||
ordering = ['price_monthly', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.get_plan_type_display()})"
|
||||
|
||||
|
||||
class TenantInvitation(models.Model):
|
||||
@@ -83,6 +325,27 @@ class TenantInvitation(models.Model):
|
||||
# "can_api_access": true,
|
||||
# }
|
||||
|
||||
# Feature limits (what capabilities this tenant has)
|
||||
limits = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Feature limits and capabilities to grant"
|
||||
)
|
||||
# Example limits structure:
|
||||
# {
|
||||
# "can_add_video_conferencing": true,
|
||||
# "max_event_types": 10, # null = unlimited
|
||||
# "max_calendars_connected": 5, # null = unlimited
|
||||
# "can_connect_to_api": true,
|
||||
# "can_book_repeated_events": true,
|
||||
# "can_require_2fa": true,
|
||||
# "can_download_logs": false,
|
||||
# "can_delete_data": false,
|
||||
# "can_use_masked_phone_numbers": false,
|
||||
# "can_use_pos": false,
|
||||
# "can_use_mobile_app": true,
|
||||
# }
|
||||
|
||||
# Personal message to include in email
|
||||
personal_message = models.TextField(
|
||||
blank=True,
|
||||
@@ -192,7 +455,7 @@ class TenantInvitation(models.Model):
|
||||
@classmethod
|
||||
def create_invitation(cls, email, invited_by, subscription_tier='PROFESSIONAL',
|
||||
suggested_business_name='', custom_max_users=None,
|
||||
custom_max_resources=None, permissions=None,
|
||||
custom_max_resources=None, permissions=None, limits=None,
|
||||
personal_message=''):
|
||||
"""
|
||||
Create a new tenant invitation, cancelling any existing pending invitations
|
||||
@@ -213,5 +476,6 @@ class TenantInvitation(models.Model):
|
||||
custom_max_users=custom_max_users,
|
||||
custom_max_resources=custom_max_resources,
|
||||
permissions=permissions or {},
|
||||
limits=limits or {},
|
||||
personal_message=personal_message,
|
||||
)
|
||||
|
||||
@@ -5,7 +5,161 @@ Serializers for platform-level operations (viewing tenants, users, metrics)
|
||||
from rest_framework import serializers
|
||||
from core.models import Tenant, Domain
|
||||
from smoothschedule.users.models import User
|
||||
from .models import TenantInvitation
|
||||
from .models import TenantInvitation, PlatformSettings, SubscriptionPlan
|
||||
|
||||
|
||||
class PlatformSettingsSerializer(serializers.Serializer):
|
||||
"""Serializer for platform settings (read-only, with masked keys)"""
|
||||
stripe_secret_key_masked = serializers.SerializerMethodField()
|
||||
stripe_publishable_key_masked = serializers.SerializerMethodField()
|
||||
stripe_webhook_secret_masked = serializers.SerializerMethodField()
|
||||
stripe_account_id = serializers.CharField(read_only=True)
|
||||
stripe_account_name = serializers.CharField(read_only=True)
|
||||
stripe_keys_validated_at = serializers.DateTimeField(read_only=True)
|
||||
stripe_validation_error = serializers.CharField(read_only=True)
|
||||
has_stripe_keys = serializers.SerializerMethodField()
|
||||
stripe_keys_from_env = serializers.SerializerMethodField()
|
||||
updated_at = serializers.DateTimeField(read_only=True)
|
||||
|
||||
def get_stripe_secret_key_masked(self, obj):
|
||||
return obj.mask_key(obj.get_stripe_secret_key())
|
||||
|
||||
def get_stripe_publishable_key_masked(self, obj):
|
||||
return obj.mask_key(obj.get_stripe_publishable_key())
|
||||
|
||||
def get_stripe_webhook_secret_masked(self, obj):
|
||||
return obj.mask_key(obj.get_stripe_webhook_secret())
|
||||
|
||||
def get_has_stripe_keys(self, obj):
|
||||
return obj.has_stripe_keys()
|
||||
|
||||
def get_stripe_keys_from_env(self, obj):
|
||||
return obj.stripe_keys_from_env()
|
||||
|
||||
|
||||
class StripeKeysUpdateSerializer(serializers.Serializer):
|
||||
"""Serializer for updating Stripe keys"""
|
||||
stripe_secret_key = serializers.CharField(required=False, allow_blank=True)
|
||||
stripe_publishable_key = serializers.CharField(required=False, allow_blank=True)
|
||||
stripe_webhook_secret = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
class OAuthSettingsSerializer(serializers.Serializer):
|
||||
"""Serializer for OAuth settings"""
|
||||
oauth_allow_registration = serializers.BooleanField(required=False, default=True)
|
||||
|
||||
# Google
|
||||
oauth_google_enabled = serializers.BooleanField(required=False, default=False)
|
||||
oauth_google_client_id = serializers.CharField(required=False, allow_blank=True)
|
||||
oauth_google_client_secret = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
# Apple
|
||||
oauth_apple_enabled = serializers.BooleanField(required=False, default=False)
|
||||
oauth_apple_client_id = serializers.CharField(required=False, allow_blank=True)
|
||||
oauth_apple_client_secret = serializers.CharField(required=False, allow_blank=True)
|
||||
oauth_apple_team_id = serializers.CharField(required=False, allow_blank=True)
|
||||
oauth_apple_key_id = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
# Facebook
|
||||
oauth_facebook_enabled = serializers.BooleanField(required=False, default=False)
|
||||
oauth_facebook_client_id = serializers.CharField(required=False, allow_blank=True)
|
||||
oauth_facebook_client_secret = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
# LinkedIn
|
||||
oauth_linkedin_enabled = serializers.BooleanField(required=False, default=False)
|
||||
oauth_linkedin_client_id = serializers.CharField(required=False, allow_blank=True)
|
||||
oauth_linkedin_client_secret = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
# Microsoft
|
||||
oauth_microsoft_enabled = serializers.BooleanField(required=False, default=False)
|
||||
oauth_microsoft_client_id = serializers.CharField(required=False, allow_blank=True)
|
||||
oauth_microsoft_client_secret = serializers.CharField(required=False, allow_blank=True)
|
||||
oauth_microsoft_tenant_id = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
# Twitter
|
||||
oauth_twitter_enabled = serializers.BooleanField(required=False, default=False)
|
||||
oauth_twitter_client_id = serializers.CharField(required=False, allow_blank=True)
|
||||
oauth_twitter_client_secret = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
# Twitch
|
||||
oauth_twitch_enabled = serializers.BooleanField(required=False, default=False)
|
||||
oauth_twitch_client_id = serializers.CharField(required=False, allow_blank=True)
|
||||
oauth_twitch_client_secret = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
class OAuthSettingsResponseSerializer(serializers.Serializer):
|
||||
"""Response serializer for OAuth settings (with masked secrets)"""
|
||||
oauth_allow_registration = serializers.BooleanField()
|
||||
google = serializers.DictField()
|
||||
apple = serializers.DictField()
|
||||
facebook = serializers.DictField()
|
||||
linkedin = serializers.DictField()
|
||||
microsoft = serializers.DictField()
|
||||
twitter = serializers.DictField()
|
||||
twitch = serializers.DictField()
|
||||
|
||||
|
||||
class SubscriptionPlanSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for subscription plans"""
|
||||
|
||||
class Meta:
|
||||
model = SubscriptionPlan
|
||||
fields = [
|
||||
'id', 'name', 'description', 'plan_type',
|
||||
'stripe_product_id', 'stripe_price_id',
|
||||
'price_monthly', 'price_yearly', 'business_tier',
|
||||
'features', 'transaction_fee_percent', 'transaction_fee_fixed',
|
||||
'is_active', 'is_public', 'created_at', 'updated_at'
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
class SubscriptionPlanCreateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating subscription plans with optional Stripe integration"""
|
||||
create_stripe_product = serializers.BooleanField(default=False, write_only=True)
|
||||
|
||||
class Meta:
|
||||
model = SubscriptionPlan
|
||||
fields = [
|
||||
'name', 'description', 'plan_type',
|
||||
'stripe_product_id', 'stripe_price_id',
|
||||
'price_monthly', 'price_yearly', 'business_tier',
|
||||
'features', 'transaction_fee_percent', 'transaction_fee_fixed',
|
||||
'is_active', 'is_public', 'create_stripe_product'
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
create_stripe = validated_data.pop('create_stripe_product', False)
|
||||
|
||||
if create_stripe and validated_data.get('price_monthly'):
|
||||
# Create Stripe product and price
|
||||
import stripe
|
||||
from django.conf import settings
|
||||
|
||||
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||||
|
||||
try:
|
||||
# Create product
|
||||
product = stripe.Product.create(
|
||||
name=validated_data['name'],
|
||||
description=validated_data.get('description', ''),
|
||||
metadata={'plan_type': validated_data.get('plan_type', 'base')}
|
||||
)
|
||||
validated_data['stripe_product_id'] = product.id
|
||||
|
||||
# Create price
|
||||
price = stripe.Price.create(
|
||||
product=product.id,
|
||||
unit_amount=int(validated_data['price_monthly'] * 100), # Convert to cents
|
||||
currency='usd',
|
||||
recurring={'interval': 'month'}
|
||||
)
|
||||
validated_data['stripe_price_id'] = price.id
|
||||
|
||||
except stripe.error.StripeError as e:
|
||||
raise serializers.ValidationError({'stripe': str(e)})
|
||||
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class TenantSerializer(serializers.ModelSerializer):
|
||||
@@ -289,7 +443,7 @@ class TenantInvitationSerializer(serializers.ModelSerializer):
|
||||
fields = [
|
||||
'id', 'email', 'token', 'status', 'suggested_business_name',
|
||||
'subscription_tier', 'custom_max_users', 'custom_max_resources',
|
||||
'permissions', 'personal_message', 'invited_by',
|
||||
'permissions', 'limits', 'personal_message', 'invited_by',
|
||||
'invited_by_email', 'created_at', 'expires_at', 'accepted_at',
|
||||
'created_tenant', 'created_tenant_name', 'created_user', 'created_user_email',
|
||||
]
|
||||
@@ -309,6 +463,27 @@ class TenantInvitationSerializer(serializers.ModelSerializer):
|
||||
raise serializers.ValidationError(f"Permission '{key}' must be a boolean.")
|
||||
return value
|
||||
|
||||
def validate_limits(self, value):
|
||||
"""Validate that limits is a dictionary with valid values"""
|
||||
if not isinstance(value, dict):
|
||||
raise serializers.ValidationError("Limits must be a dictionary.")
|
||||
|
||||
# Validate each key's value type
|
||||
boolean_keys = [
|
||||
'can_add_video_conferencing', 'can_connect_to_api', 'can_book_repeated_events',
|
||||
'can_require_2fa', 'can_download_logs', 'can_delete_data',
|
||||
'can_use_masked_phone_numbers', 'can_use_pos', 'can_use_mobile_app'
|
||||
]
|
||||
integer_keys = ['max_event_types', 'max_calendars_connected']
|
||||
|
||||
for key, val in value.items():
|
||||
if key in boolean_keys and not isinstance(val, bool):
|
||||
raise serializers.ValidationError(f"Limit '{key}' must be a boolean.")
|
||||
if key in integer_keys and val is not None and not isinstance(val, int):
|
||||
raise serializers.ValidationError(f"Limit '{key}' must be an integer or null.")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class TenantInvitationCreateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating TenantInvitations - sets invited_by automatically"""
|
||||
@@ -316,10 +491,31 @@ class TenantInvitationCreateSerializer(serializers.ModelSerializer):
|
||||
model = TenantInvitation
|
||||
fields = [
|
||||
'email', 'suggested_business_name', 'subscription_tier',
|
||||
'custom_max_users', 'custom_max_resources', 'permissions',
|
||||
'custom_max_users', 'custom_max_resources', 'permissions', 'limits',
|
||||
'personal_message',
|
||||
]
|
||||
|
||||
def validate_limits(self, value):
|
||||
"""Validate that limits is a dictionary with valid values"""
|
||||
if not isinstance(value, dict):
|
||||
raise serializers.ValidationError("Limits must be a dictionary.")
|
||||
|
||||
# Validate each key's value type
|
||||
boolean_keys = [
|
||||
'can_add_video_conferencing', 'can_connect_to_api', 'can_book_repeated_events',
|
||||
'can_require_2fa', 'can_download_logs', 'can_delete_data',
|
||||
'can_use_masked_phone_numbers', 'can_use_pos', 'can_use_mobile_app'
|
||||
]
|
||||
integer_keys = ['max_event_types', 'max_calendars_connected']
|
||||
|
||||
for key, val in value.items():
|
||||
if key in boolean_keys and not isinstance(val, bool):
|
||||
raise serializers.ValidationError(f"Limit '{key}' must be a boolean.")
|
||||
if key in integer_keys and val is not None and not isinstance(val, int):
|
||||
raise serializers.ValidationError(f"Limit '{key}' must be an integer or null.")
|
||||
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['invited_by'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
|
||||
209
smoothschedule/platform_admin/tasks.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
Celery tasks for platform admin operations.
|
||||
"""
|
||||
from celery import shared_task
|
||||
from django.core.mail import send_mail, EmailMultiAlternatives
|
||||
from django.template.loader import render_to_string
|
||||
from django.conf import settings
|
||||
from django.utils.html import strip_tags
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_base_url():
|
||||
"""Get the base URL for the platform."""
|
||||
# In production, this should come from settings
|
||||
return getattr(settings, 'PLATFORM_BASE_URL', 'http://platform.lvh.me:5173')
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3)
|
||||
def send_tenant_invitation_email(self, invitation_id: int):
|
||||
"""
|
||||
Send an invitation email to a prospective tenant.
|
||||
|
||||
Args:
|
||||
invitation_id: ID of the TenantInvitation to send
|
||||
"""
|
||||
from .models import TenantInvitation
|
||||
|
||||
try:
|
||||
invitation = TenantInvitation.objects.select_related('invited_by').get(id=invitation_id)
|
||||
except TenantInvitation.DoesNotExist:
|
||||
logger.error(f"TenantInvitation {invitation_id} not found")
|
||||
return {'success': False, 'error': 'Invitation not found'}
|
||||
|
||||
# Don't send if already accepted or expired
|
||||
if invitation.status != TenantInvitation.Status.PENDING:
|
||||
logger.info(f"Skipping email for invitation {invitation_id} - status is {invitation.status}")
|
||||
return {'success': False, 'error': f'Invitation status is {invitation.status}'}
|
||||
|
||||
if not invitation.is_valid():
|
||||
logger.info(f"Skipping email for invitation {invitation_id} - invitation expired")
|
||||
return {'success': False, 'error': 'Invitation expired'}
|
||||
|
||||
try:
|
||||
# Build the invitation URL
|
||||
base_url = get_base_url()
|
||||
invitation_url = f"{base_url}/accept-invite/{invitation.token}"
|
||||
|
||||
# Email context
|
||||
context = {
|
||||
'invitation': invitation,
|
||||
'invitation_url': invitation_url,
|
||||
'inviter_name': invitation.invited_by.get_full_name() or invitation.invited_by.email if invitation.invited_by else 'SmoothSchedule Team',
|
||||
'suggested_business_name': invitation.suggested_business_name or 'your new business',
|
||||
'personal_message': invitation.personal_message,
|
||||
'expires_at': invitation.expires_at,
|
||||
}
|
||||
|
||||
# Render HTML email
|
||||
html_content = render_to_string('platform_admin/emails/tenant_invitation.html', context)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
# Subject line
|
||||
subject = f"You're invited to join SmoothSchedule!"
|
||||
if invitation.invited_by:
|
||||
inviter = invitation.invited_by.get_full_name() or invitation.invited_by.email
|
||||
subject = f"{inviter} has invited you to SmoothSchedule"
|
||||
|
||||
# Send email
|
||||
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@smoothschedule.com')
|
||||
|
||||
email = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=from_email,
|
||||
to=[invitation.email],
|
||||
)
|
||||
email.attach_alternative(html_content, "text/html")
|
||||
email.send()
|
||||
|
||||
logger.info(f"Sent invitation email to {invitation.email} for invitation {invitation_id}")
|
||||
return {'success': True, 'email': invitation.email}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send invitation email for {invitation_id}: {e}", exc_info=True)
|
||||
# Retry with exponential backoff
|
||||
raise self.retry(exc=e, countdown=60 * (2 ** self.request.retries))
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3)
|
||||
def send_appointment_reminder_email(self, event_id: int, customer_email: str, hours_before: int = 24):
|
||||
"""
|
||||
Send an appointment reminder email to a customer.
|
||||
|
||||
Args:
|
||||
event_id: ID of the Event
|
||||
customer_email: Email address to send reminder to
|
||||
hours_before: Hours before the appointment (for context in email)
|
||||
"""
|
||||
from schedule.models import Event
|
||||
|
||||
try:
|
||||
event = Event.objects.select_related('created_by').prefetch_related(
|
||||
'participants'
|
||||
).get(id=event_id)
|
||||
except Event.DoesNotExist:
|
||||
logger.error(f"Event {event_id} not found")
|
||||
return {'success': False, 'error': 'Event not found'}
|
||||
|
||||
# Don't send for cancelled events
|
||||
if event.status == Event.Status.CANCELED:
|
||||
logger.info(f"Skipping reminder for event {event_id} - event is cancelled")
|
||||
return {'success': False, 'error': 'Event cancelled'}
|
||||
|
||||
try:
|
||||
# Get resources/staff for the event
|
||||
staff_names = []
|
||||
for participant in event.participants.filter(role__in=['RESOURCE', 'STAFF']):
|
||||
if participant.content_object and hasattr(participant.content_object, 'name'):
|
||||
staff_names.append(participant.content_object.name)
|
||||
|
||||
# Get business info from tenant
|
||||
from django.db import connection
|
||||
business_name = 'SmoothSchedule'
|
||||
if hasattr(connection, 'tenant'):
|
||||
business_name = connection.tenant.name
|
||||
|
||||
# Email context
|
||||
context = {
|
||||
'event': event,
|
||||
'customer_email': customer_email,
|
||||
'business_name': business_name,
|
||||
'staff_names': staff_names,
|
||||
'hours_before': hours_before,
|
||||
'event_date': event.start_time.strftime('%A, %B %d, %Y'),
|
||||
'event_time': event.start_time.strftime('%I:%M %p'),
|
||||
'duration_minutes': int(event.duration.total_seconds() // 60) if hasattr(event.duration, 'total_seconds') else event.duration,
|
||||
}
|
||||
|
||||
# Render HTML email
|
||||
html_content = render_to_string('schedule/emails/appointment_reminder.html', context)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
# Subject line
|
||||
subject = f"Reminder: Your appointment at {business_name} on {context['event_date']}"
|
||||
|
||||
# Send email
|
||||
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@smoothschedule.com')
|
||||
|
||||
email = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=from_email,
|
||||
to=[customer_email],
|
||||
)
|
||||
email.attach_alternative(html_content, "text/html")
|
||||
email.send()
|
||||
|
||||
logger.info(f"Sent appointment reminder to {customer_email} for event {event_id}")
|
||||
return {'success': True, 'email': customer_email, 'event_id': event_id}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send appointment reminder for event {event_id}: {e}", exc_info=True)
|
||||
raise self.retry(exc=e, countdown=60 * (2 ** self.request.retries))
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_bulk_appointment_reminders(hours_before: int = 24):
|
||||
"""
|
||||
Find all appointments happening in X hours and send reminder emails.
|
||||
|
||||
This is typically run periodically by Celery Beat.
|
||||
|
||||
Args:
|
||||
hours_before: How many hours before the appointment to send reminders
|
||||
"""
|
||||
from schedule.models import Event
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
now = timezone.now()
|
||||
reminder_window_start = now + timedelta(hours=hours_before - 0.5)
|
||||
reminder_window_end = now + timedelta(hours=hours_before + 0.5)
|
||||
|
||||
# Find events in the reminder window
|
||||
events = Event.objects.filter(
|
||||
start_time__gte=reminder_window_start,
|
||||
start_time__lte=reminder_window_end,
|
||||
status=Event.Status.SCHEDULED,
|
||||
).prefetch_related('participants__customer')
|
||||
|
||||
reminders_queued = 0
|
||||
for event in events:
|
||||
# Get customer emails from participants
|
||||
for participant in event.participants.all():
|
||||
if participant.customer and hasattr(participant.customer, 'email'):
|
||||
customer_email = participant.customer.email
|
||||
if customer_email:
|
||||
# Queue the reminder email
|
||||
send_appointment_reminder_email.delay(
|
||||
event_id=event.id,
|
||||
customer_email=customer_email,
|
||||
hours_before=hours_before
|
||||
)
|
||||
reminders_queued += 1
|
||||
|
||||
logger.info(f"Queued {reminders_queued} appointment reminder emails for {hours_before}h window")
|
||||
return {'reminders_queued': reminders_queued, 'hours_before': hours_before}
|
||||
@@ -0,0 +1,167 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>You're Invited to SmoothSchedule</title>
|
||||