445 lines
22 KiB
TypeScript
445 lines
22 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useTransition, useCallback } from "react";
|
||
import { useSession } from "next-auth/react";
|
||
import { formatBytes } from "@/lib/format";
|
||
|
||
interface Mailbox {
|
||
username: string;
|
||
name: string;
|
||
local_part: string;
|
||
domain: string;
|
||
quota: number;
|
||
quota_used: number;
|
||
active: string; // "1" | "0"
|
||
}
|
||
|
||
interface Domain {
|
||
domain_name: string;
|
||
}
|
||
|
||
export default function MailboxesPage() {
|
||
const { data: session } = useSession();
|
||
const [domains, setDomains] = useState<Domain[]>([]);
|
||
const [selectedDomain, setSelectedDomain] = useState("");
|
||
const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||
const [showPasswordModal, setShowPasswordModal] = useState<string | null>(null);
|
||
const [showInfoModal, setShowInfoModal] = useState<string | null>(null);
|
||
const [isPending, startTransition] = useTransition();
|
||
const [search, setSearch] = useState("");
|
||
const [createForm, setCreateForm] = useState({ local_part: "", name: "", password: "", quota: 3072 });
|
||
const [newPassword, setNewPassword] = useState("");
|
||
const [formError, setFormError] = useState("");
|
||
|
||
useEffect(() => {
|
||
fetch("/api/domains")
|
||
.then((r) => r.json())
|
||
.then((data: Domain[]) => {
|
||
if (Array.isArray(data) && data.length > 0) {
|
||
setDomains(data);
|
||
setSelectedDomain(data[0].domain_name);
|
||
}
|
||
});
|
||
}, []);
|
||
|
||
const fetchMailboxes = useCallback(async (domain: string) => {
|
||
if (!domain) return;
|
||
setLoading(true);
|
||
const res = await fetch(`/api/mailboxes?domain=${encodeURIComponent(domain)}`);
|
||
const data = await res.json();
|
||
setMailboxes(Array.isArray(data) ? data : []);
|
||
setLoading(false);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (selectedDomain) fetchMailboxes(selectedDomain);
|
||
}, [selectedDomain, fetchMailboxes]);
|
||
|
||
const handleCreate = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setFormError("");
|
||
startTransition(async () => {
|
||
const res = await fetch("/api/mailboxes", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ ...createForm, domain: selectedDomain }),
|
||
});
|
||
const data = await res.json();
|
||
if (res.ok) {
|
||
setShowCreateModal(false);
|
||
setCreateForm({ local_part: "", name: "", password: "", quota: 3072 });
|
||
fetchMailboxes(selectedDomain);
|
||
} else {
|
||
const msg = Array.isArray(data)
|
||
? data.map((d: { msg?: unknown }) => JSON.stringify(d.msg)).join(", ")
|
||
: (data?.error ?? "Mailcow bağlantısını kontrol edin");
|
||
setFormError(String(msg));
|
||
}
|
||
});
|
||
};
|
||
|
||
const handleDelete = (username: string) => {
|
||
if (!confirm(`"${username}" hesabını silmek istediğinizden emin misiniz?`)) return;
|
||
startTransition(async () => {
|
||
await fetch(`/api/mailboxes/${encodeURIComponent(username)}`, { method: "DELETE" });
|
||
setMailboxes((prev) => prev.filter((m) => m.username !== username));
|
||
});
|
||
};
|
||
|
||
const handleToggle = (username: string, active: string) => {
|
||
const newActive = String(active) === "1" ? 0 : 1;
|
||
startTransition(async () => {
|
||
await fetch(`/api/mailboxes/${encodeURIComponent(username)}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ active: newActive }),
|
||
});
|
||
setMailboxes((prev) =>
|
||
prev.map((m) => m.username === username ? { ...m, active: String(newActive) } : m)
|
||
);
|
||
});
|
||
};
|
||
|
||
const handlePasswordChange = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!showPasswordModal) return;
|
||
startTransition(async () => {
|
||
await fetch(`/api/mailboxes/${encodeURIComponent(showPasswordModal)}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ password: newPassword }),
|
||
});
|
||
setShowPasswordModal(null);
|
||
setNewPassword("");
|
||
});
|
||
};
|
||
|
||
const filtered = mailboxes.filter(
|
||
(m) =>
|
||
m.username.toLowerCase().includes(search.toLowerCase()) ||
|
||
m.name.toLowerCase().includes(search.toLowerCase())
|
||
);
|
||
|
||
const isSuperAdmin = session?.user?.role === "SUPER_ADMIN";
|
||
|
||
return (
|
||
<>
|
||
<div className="page-header">
|
||
<div>
|
||
<h1 className="page-title">Mail Hesapları</h1>
|
||
<p className="page-subtitle">
|
||
{selectedDomain
|
||
? `${selectedDomain} — ${mailboxes.length} hesap`
|
||
: "Domain seçin"}
|
||
</p>
|
||
</div>
|
||
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
|
||
<select
|
||
className="input"
|
||
style={{ width: "auto", minWidth: 200 }}
|
||
value={selectedDomain}
|
||
onChange={(e) => setSelectedDomain(e.target.value)}
|
||
>
|
||
{domains.map((d) => (
|
||
<option key={d.domain_name} value={d.domain_name}>
|
||
{d.domain_name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button
|
||
className="btn btn-ghost"
|
||
onClick={() => fetchMailboxes(selectedDomain)}
|
||
disabled={!selectedDomain}
|
||
>
|
||
<RefreshIcon />
|
||
</button>
|
||
<button
|
||
className="btn btn-primary"
|
||
onClick={() => setShowCreateModal(true)}
|
||
disabled={!selectedDomain}
|
||
>
|
||
<PlusIcon /> Hesap Ekle
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="page-body">
|
||
<div className="search-bar">
|
||
<div className="search-input-wrap">
|
||
<span className="search-icon"><SearchIcon /></span>
|
||
<input
|
||
type="text"
|
||
className="input search-input"
|
||
placeholder="E-posta veya isim ara..."
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="table-wrap">
|
||
{loading ? (
|
||
<div className="empty-state">
|
||
<span className="spinner" style={{ width: 24, height: 24 }} />
|
||
</div>
|
||
) : filtered.length === 0 ? (
|
||
<div className="empty-state">
|
||
<div className="empty-icon"><MailIcon size={24} /></div>
|
||
<div style={{ fontWeight: 600 }}>
|
||
{selectedDomain ? "Bu domainde mail hesabı yok" : "Domain seçin"}
|
||
</div>
|
||
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
||
{selectedDomain ? '"Hesap Ekle" butonuna tıklayın' : "Sol üstteki listeden domain seçin"}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>E-posta</th>
|
||
<th>Ad Soyad</th>
|
||
<th>Kota</th>
|
||
<th>Durum</th>
|
||
<th>İşlemler</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filtered.map((m) => {
|
||
const usedPct = m.quota > 0 ? Math.min((m.quota_used / m.quota) * 100, 100) : 0;
|
||
return (
|
||
<tr key={m.username}>
|
||
<td>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||
<div style={{
|
||
width: 30, height: 30, borderRadius: 6,
|
||
background: "var(--accent-dim)", color: "var(--accent-hover)",
|
||
display: "flex", alignItems: "center", justifyContent: "center",
|
||
flexShrink: 0, fontSize: 13, fontWeight: 700,
|
||
}}>
|
||
{m.local_part[0]?.toUpperCase()}
|
||
</div>
|
||
<span style={{ fontWeight: 500 }}>{m.username}</span>
|
||
</div>
|
||
</td>
|
||
<td style={{ color: "var(--text-secondary)" }}>{m.name}</td>
|
||
<td style={{ minWidth: 160 }}>
|
||
<div style={{ fontSize: 11, color: "var(--text-secondary)", marginBottom: 4 }}>
|
||
{formatBytes(m.quota_used)} / {formatBytes(m.quota)}
|
||
</div>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||
<div className="progress-bar">
|
||
<div className={`progress-fill ${usedPct > 80 ? "danger" : ""}`} style={{ width: `${usedPct}%` }} />
|
||
</div>
|
||
<span style={{ fontSize: 11, color: "var(--text-muted)", flexShrink: 0 }}>{Math.round(usedPct)}%</span>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<span className={`badge ${String(m.active) === "1" ? "badge-green" : "badge-red"}`}>
|
||
{String(m.active) === "1" ? "● Aktif" : "● Pasif"}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<div style={{ display: "flex", gap: 6 }}>
|
||
<button
|
||
className="btn btn-ghost btn-sm"
|
||
onClick={() => setShowInfoModal(m.username)}
|
||
title="Bağlantı Bilgileri"
|
||
>
|
||
<InfoIcon />
|
||
</button>
|
||
<button
|
||
className="btn btn-ghost btn-sm"
|
||
onClick={() => setShowPasswordModal(m.username)}
|
||
title="Şifre Değiştir"
|
||
>
|
||
<KeyIcon />
|
||
</button>
|
||
<button
|
||
className={`btn btn-sm ${String(m.active) === "1" ? "btn-ghost" : "btn-success"}`}
|
||
onClick={() => handleToggle(m.username, m.active)}
|
||
title={String(m.active) === "1" ? "Pasife Al" : "Aktif Et"}
|
||
>
|
||
{String(m.active) === "1" ? <PauseIcon /> : <PlayIcon />}
|
||
</button>
|
||
<button
|
||
className="btn btn-danger btn-sm"
|
||
onClick={() => handleDelete(m.username)}
|
||
title="Sil"
|
||
>
|
||
<TrashIcon />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Create Modal */}
|
||
{showCreateModal && (
|
||
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && setShowCreateModal(false)}>
|
||
<div className="modal">
|
||
<div className="modal-header">
|
||
<h2 className="modal-title">Yeni Mail Hesabı</h2>
|
||
<button className="modal-close" onClick={() => setShowCreateModal(false)}><XIcon /></button>
|
||
</div>
|
||
<form onSubmit={handleCreate}>
|
||
<div className="modal-body form-group">
|
||
{formError && <div className="error-msg">{formError}</div>}
|
||
<div>
|
||
<label className="label">Kullanıcı Adı</label>
|
||
<div style={{ display: "flex" }}>
|
||
<input
|
||
type="text" className="input"
|
||
style={{ borderRadius: "var(--radius) 0 0 var(--radius)" }}
|
||
placeholder="info"
|
||
value={createForm.local_part}
|
||
onChange={(e) => setCreateForm({ ...createForm, local_part: e.target.value })}
|
||
required
|
||
/>
|
||
<span style={{
|
||
background: "var(--bg-hover)", border: "1px solid var(--border)", borderLeft: "none",
|
||
padding: "8px 12px", borderRadius: "0 var(--radius) var(--radius) 0",
|
||
color: "var(--text-secondary)", fontSize: 13, whiteSpace: "nowrap",
|
||
}}>
|
||
@{selectedDomain}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="label">Ad Soyad</label>
|
||
<input type="text" className="input" placeholder="Emina Karabudak"
|
||
value={createForm.name}
|
||
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
|
||
required />
|
||
</div>
|
||
<div>
|
||
<label className="label">Şifre</label>
|
||
<input type="password" className="input" placeholder="Güçlü bir şifre"
|
||
value={createForm.password}
|
||
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
|
||
required />
|
||
</div>
|
||
<div>
|
||
<label className="label">Kota (MB)</label>
|
||
<input type="number" className="input" value={createForm.quota} min={100} max={102400}
|
||
onChange={(e) => setCreateForm({ ...createForm, quota: parseInt(e.target.value) || 3072 })} />
|
||
</div>
|
||
</div>
|
||
<div className="modal-footer">
|
||
<button type="button" className="btn btn-ghost" onClick={() => setShowCreateModal(false)}>İptal</button>
|
||
<button type="submit" className="btn btn-primary" disabled={isPending}>
|
||
{isPending ? <span className="spinner" /> : <PlusIcon />} Oluştur
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Password Modal */}
|
||
{showPasswordModal && (
|
||
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && setShowPasswordModal(null)}>
|
||
<div className="modal">
|
||
<div className="modal-header">
|
||
<h2 className="modal-title">Şifre Değiştir</h2>
|
||
<button className="modal-close" onClick={() => setShowPasswordModal(null)}><XIcon /></button>
|
||
</div>
|
||
<form onSubmit={handlePasswordChange}>
|
||
<div className="modal-body form-group">
|
||
<div style={{ fontSize: 13, color: "var(--text-secondary)" }}>
|
||
<strong style={{ color: "var(--text-primary)" }}>{showPasswordModal}</strong> için yeni şifre
|
||
</div>
|
||
<div>
|
||
<label className="label">Yeni Şifre</label>
|
||
<input type="password" className="input" placeholder="Yeni şifre"
|
||
value={newPassword}
|
||
onChange={(e) => setNewPassword(e.target.value)}
|
||
required autoFocus />
|
||
</div>
|
||
</div>
|
||
<div className="modal-footer">
|
||
<button type="button" className="btn btn-ghost" onClick={() => setShowPasswordModal(null)}>İptal</button>
|
||
<button type="submit" className="btn btn-primary" disabled={isPending}>
|
||
{isPending ? <span className="spinner" /> : <KeyIcon />} Güncelle
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* Info Modal */}
|
||
{showInfoModal && (
|
||
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && setShowInfoModal(null)}>
|
||
<div className="modal">
|
||
<div className="modal-header">
|
||
<h2 className="modal-title">İstemci Bağlantı Bilgileri</h2>
|
||
<button className="modal-close" onClick={() => setShowInfoModal(null)}><XIcon /></button>
|
||
</div>
|
||
<div className="modal-body form-group">
|
||
<div style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: 10 }}>
|
||
<strong>{showInfoModal}</strong> hesabını Apple Mail, Outlook veya telefonunuza kurmak için aşağıdaki bilgileri kullanın:
|
||
</div>
|
||
|
||
<div style={{ background: "var(--bg-hover)", padding: 12, borderRadius: "var(--radius)", border: "1px solid var(--border)", fontSize: 13 }}>
|
||
<div style={{ marginBottom: 12 }}>
|
||
<div style={{ color: "var(--text-secondary)", fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: 0.5 }}>IMAP (Gelen Sunucu)</div>
|
||
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 4, alignItems: "center" }}>
|
||
<span>Sunucu: <strong>mail.ayris.tech</strong></span>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||
<button className="btn btn-ghost btn-sm" style={{ padding: "4px 8px", fontSize: 11 }} onClick={() => navigator.clipboard.writeText("mail.ayris.tech")} title="Kopyala"><CopyIcon /> Kopyala</button>
|
||
</div>
|
||
</div>
|
||
<div style={{ marginTop: 2 }}>Port: <strong>993</strong> (SSL/TLS)</div>
|
||
</div>
|
||
|
||
<div style={{ marginBottom: 12, paddingTop: 12, borderTop: "1px solid var(--border)" }}>
|
||
<div style={{ color: "var(--text-secondary)", fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: 0.5 }}>SMTP (Giden Sunucu)</div>
|
||
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 4, alignItems: "center" }}>
|
||
<span>Sunucu: <strong>mail.ayris.tech</strong></span>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||
<button className="btn btn-ghost btn-sm" style={{ padding: "4px 8px", fontSize: 11 }} onClick={() => navigator.clipboard.writeText("mail.ayris.tech")} title="Kopyala"><CopyIcon /> Kopyala</button>
|
||
</div>
|
||
</div>
|
||
<div style={{ marginTop: 2 }}>Port: <strong>587</strong> (STARTTLS) <span style={{ color: "var(--text-secondary)" }}>veya 465 (SSL)</span></div>
|
||
</div>
|
||
|
||
<div style={{ paddingTop: 12, borderTop: "1px solid var(--border)" }}>
|
||
<div style={{ color: "var(--text-secondary)", fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: 0.5 }}>Kimlik Doğrulama</div>
|
||
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 4, alignItems: "center" }}>
|
||
<span>Kullanıcı Adı: <strong>{showInfoModal}</strong></span>
|
||
<button className="btn btn-ghost btn-sm" style={{ padding: "4px 8px", fontSize: 11 }} onClick={() => navigator.clipboard.writeText(showInfoModal)} title="Kopyala"><CopyIcon /> Kopyala</button>
|
||
</div>
|
||
<div style={{ color: "var(--text-secondary)", marginTop: 4 }}>Şifre: <span style={{ fontStyle: "italic" }}>Hesap oluştururken belirlediğiniz şifre</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="modal-footer">
|
||
<button type="button" className="btn btn-primary" onClick={() => setShowInfoModal(null)}>Tamam</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// Icons
|
||
function PlusIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>; }
|
||
function SearchIcon() { return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>; }
|
||
function RefreshIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/></svg>; }
|
||
function MailIcon({ size = 13 }: { size?: number }) { return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>; }
|
||
function TrashIcon() { return <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>; }
|
||
function KeyIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/></svg>; }
|
||
function PauseIcon() { return <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>; }
|
||
function PlayIcon() { return <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>; }
|
||
function XIcon() { return <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>; }
|
||
function InfoIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>; }
|
||
function CopyIcon() { return <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>; }
|