Files
boc/web-v2/src/components/mail/ComposeModal.tsx
T
Bernt 78b57273e2 security: Add proper authentication, RBAC, and tenant isolation
- 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
2026-08-10 12:52:48 +00:00

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