78b57273e2
- Add password hashing with bcrypt - Add AuthService with proper login - Add password strength validation - Add RBAC middleware (AdminOnly, ManagerOrAdmin) - Add tenant isolation middleware - Update CRM handler with tenant filtering - Add JWT fallback for development mode - Add user context helpers - Build successful
210 lines
6.7 KiB
TypeScript
210 lines
6.7 KiB
TypeScript
import { useState, useRef } from 'react'
|
|
import { Button } from '@/components/ui/Button'
|
|
import { Card } from '@/components/ui/Card'
|
|
import { X, Send, Paperclip, Sparkles, Loader2 } from 'lucide-react'
|
|
|
|
interface ComposeModalProps {
|
|
isOpen: boolean
|
|
onClose: () => void
|
|
replyTo?: {
|
|
uid: number
|
|
subject: string
|
|
from: string
|
|
body: string
|
|
}
|
|
onSent?: () => void
|
|
}
|
|
|
|
export function ComposeModal({ isOpen, onClose, replyTo, onSent }: ComposeModalProps) {
|
|
const [to, setTo] = useState(replyTo ? extractEmail(replyTo.from) : '')
|
|
const [subject, setSubject] = useState(replyTo ? `Re: ${replyTo.subject.replace(/^Re: /i, '')}` : '')
|
|
const [body, setBody] = useState(replyTo ? `\n\n---\n${replyTo.body.substring(0, 500)}` : '')
|
|
const [sending, setSending] = useState(false)
|
|
const [aiLoading, setAiLoading] = useState(false)
|
|
const [attachments, setAttachments] = useState<File[]>([])
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
if (!isOpen) return null
|
|
|
|
function extractEmail(from: string): string {
|
|
const match = from.match(/<([^>]+)>/)
|
|
return match ? match[1] : from
|
|
}
|
|
|
|
async function handleSend() {
|
|
if (!to || !subject || !body) return
|
|
|
|
setSending(true)
|
|
try {
|
|
const token = localStorage.getItem('amos_token')
|
|
const res = await fetch('/api/v1/mail/send', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({
|
|
to: [to],
|
|
subject,
|
|
body,
|
|
reply_to: replyTo?.from,
|
|
}),
|
|
})
|
|
|
|
const data = await res.json()
|
|
if (data.ok) {
|
|
setTo('')
|
|
setSubject('')
|
|
setBody('')
|
|
setAttachments([])
|
|
onSent?.()
|
|
onClose()
|
|
} else {
|
|
alert(data.error || 'Failed to send')
|
|
}
|
|
} catch (err) {
|
|
alert('Failed to send email')
|
|
} finally {
|
|
setSending(false)
|
|
}
|
|
}
|
|
|
|
async function handleAIAssist() {
|
|
if (!body.trim()) return
|
|
setAiLoading(true)
|
|
try {
|
|
const token = localStorage.getItem('amos_token')
|
|
const res = await fetch('/api/v1/mail/ai-assist', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({
|
|
context: body,
|
|
tone: 'professional',
|
|
language: 'sv',
|
|
}),
|
|
})
|
|
|
|
const data = await res.json()
|
|
if (data.ok && data.improved) {
|
|
setBody(data.improved)
|
|
}
|
|
} catch (err) {
|
|
console.error('AI assist failed:', err)
|
|
} finally {
|
|
setAiLoading(false)
|
|
}
|
|
}
|
|
|
|
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
|
if (e.target.files) {
|
|
setAttachments(Array.from(e.target.files))
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
|
<Card className="w-full max-w-2xl max-h-[90vh] flex flex-col">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
|
<h2 className="text-lg font-semibold text-text-primary">
|
|
{replyTo ? 'Svara' : 'Nytt meddelande'}
|
|
</h2>
|
|
<button onClick={onClose} className="p-2 rounded-lg hover:bg-bg text-text-secondary">
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Form */}
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-secondary mb-1">Till</label>
|
|
<input
|
|
type="email"
|
|
value={to}
|
|
onChange={(e) => setTo(e.target.value)}
|
|
className="w-full h-10 px-3 rounded-xl border bg-surface text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
|
|
placeholder="email@example.com"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-secondary mb-1">Ämne</label>
|
|
<input
|
|
type="text"
|
|
value={subject}
|
|
onChange={(e) => setSubject(e.target.value)}
|
|
className="w-full h-10 px-3 rounded-xl border bg-surface text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
|
|
placeholder="Ämne"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-secondary mb-1">Meddelande</label>
|
|
<textarea
|
|
value={body}
|
|
onChange={(e) => setBody(e.target.value)}
|
|
rows={12}
|
|
className="w-full px-3 py-2 rounded-xl border bg-surface text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-primary/20 resize-none"
|
|
placeholder="Skriv ditt meddelande..."
|
|
/>
|
|
</div>
|
|
|
|
{attachments.length > 0 && (
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-medium text-text-secondary">Bilagor</p>
|
|
{attachments.map((file, i) => (
|
|
<div key={i} className="flex items-center gap-2 p-2 bg-bg rounded-lg">
|
|
<Paperclip size={16} className="text-text-secondary" />
|
|
<span className="text-sm text-text-primary">{file.name}</span>
|
|
<span className="text-xs text-text-secondary">({(file.size / 1024).toFixed(0)} KB)</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center justify-between p-4 border-t border-border">
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="file"
|
|
ref={fileInputRef}
|
|
onChange={handleFileSelect}
|
|
multiple
|
|
className="hidden"
|
|
/>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
icon={<Paperclip size={16} />}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
>
|
|
Bifoga
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
icon={aiLoading ? <Loader2 size={16} className="animate-spin" /> : <Sparkles size={16} />}
|
|
onClick={handleAIAssist}
|
|
disabled={aiLoading || !body.trim()}
|
|
>
|
|
AI-hjälp
|
|
</Button>
|
|
</div>
|
|
|
|
<Button
|
|
icon={sending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
|
onClick={handleSend}
|
|
disabled={sending || !to || !subject || !body}
|
|
>
|
|
{sending ? 'Skickar...' : 'Skicka'}
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|