Implement database migration, notification logs, and one-click Mailcow setup

This commit is contained in:
AyrisAI
2026-05-14 16:49:11 +03:00
parent f328296c64
commit b024e20027
18 changed files with 1067 additions and 166 deletions

View File

@@ -0,0 +1,131 @@
"use client";
import { useState, useEffect } from "react";
import { useDictionary } from "@/components/DictionaryContext";
interface Log {
id: string;
mailbox: string;
sender: string | null;
subject: string | null;
status: string;
error: string | null;
createdAt: string;
user?: {
name: string | null;
email: string;
} | null;
}
export default function LogsPage() {
const [logs, setLogs] = useState<Log[]>([]);
const [loading, setLoading] = useState(true);
const dict = useDictionary();
const fetchLogs = async () => {
setLoading(true);
try {
const res = await fetch("/api/logs");
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP error! status: ${res.status}`);
}
const data = await res.json();
if (Array.isArray(data)) setLogs(data);
} catch (error: any) {
console.error("Failed to fetch logs:", error);
alert(error.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchLogs();
}, []);
return (
<>
<div className="page-header">
<div>
<h1 className="page-title">{dict.logs?.title || "Bildirim Logları"}</h1>
<p className="page-subtitle">{dict.logs?.subtitle || "Son gönderilen bildirimlerin durumu"}</p>
</div>
<button className="btn btn-ghost" onClick={fetchLogs} disabled={loading}>
<RefreshIcon />
</button>
</div>
<div className="page-body">
<div className="table-wrap">
{loading ? (
<div className="empty-state">
<span className="spinner" style={{ width: 24, height: 24 }} />
</div>
) : logs.length === 0 ? (
<div className="empty-state">
<div style={{ fontWeight: 600 }}>{dict.logs?.noLogs || "Log kaydı bulunamadı"}</div>
</div>
) : (
<table>
<thead>
<tr>
<th>{dict.logs?.mailbox || "Alıcı"}</th>
<th>{dict.logs?.sender || "Gönderen"} / {dict.logs?.subject || "Konu"}</th>
<th>{dict.logs?.status || "Durum"}</th>
<th>{dict.logs?.date || "Tarih"}</th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr key={log.id}>
<td>
<div style={{ fontWeight: 500 }}>{log.mailbox}</div>
{log.user && (
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>
{log.user.name || log.user.email}
</div>
)}
</td>
<td>
<div style={{ fontSize: 13, fontWeight: 500 }}>{log.sender || "Unknown"}</div>
<div style={{ fontSize: 12, color: "var(--text-secondary)", maxWidth: 300, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{log.subject || "(No Subject)"}
</div>
</td>
<td>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span className={`badge ${log.status === "SENT" ? "badge-green" : "badge-red"}`} style={{ width: "fit-content" }}>
{log.status === "SENT" ? (dict.logs?.sent || "GÖNDERİLDİ") : (dict.logs?.failed || "HATA")}
</span>
{log.error && (
<div style={{ fontSize: 10, color: "var(--text-red)", maxWidth: 200, wordBreak: "break-word" }}>
{log.error}
</div>
)}
</div>
</td>
<td style={{ fontSize: 12, color: "var(--text-secondary)" }}>
{new Date(log.createdAt).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</>
);
}
function RefreshIcon() {
return (
<svg width="15" height="15" 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>
);
}

View File

@@ -19,6 +19,13 @@ interface Domain {
domain_name: string;
}
interface User {
id: string;
name: string;
email: string;
telegramId?: string;
}
export default function MailboxesPage() {
const { data: session } = useSession();
const [domains, setDomains] = useState<Domain[]>([]);
@@ -30,12 +37,14 @@ export default function MailboxesPage() {
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 [users, setUsers] = useState<User[]>([]);
const [createForm, setCreateForm] = useState({ local_part: "", name: "", password: "", quota: 3072, notifyUserId: "" });
const [newPassword, setNewPassword] = useState("");
const [formError, setFormError] = useState("");
const dict = useDictionary();
useEffect(() => {
// Fetch domains
fetch("/api/domains")
.then((r) => r.json())
.then((data: Domain[]) => {
@@ -44,6 +53,13 @@ export default function MailboxesPage() {
setSelectedDomain(data[0].domain_name);
}
});
// Fetch users for mapping selection
fetch("/api/users")
.then((r) => r.json())
.then((data) => {
if (Array.isArray(data)) setUsers(data);
});
}, []);
const fetchMailboxes = useCallback(async (domain: string) => {
@@ -63,22 +79,47 @@ export default function MailboxesPage() {
e.preventDefault();
setFormError("");
startTransition(async () => {
try {
const res = await fetch("/api/mailboxes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...createForm, domain: selectedDomain }),
});
const data = await res.json();
let data: any = {};
try {
data = await res.json();
} catch (e) {
data = { error: "Sunucudan geçersiz yanıt geldi (JSON hatası)." };
}
if (res.ok) {
// If a notification user is selected, create the mapping
if (createForm.notifyUserId) {
const fullEmail = `${createForm.local_part}@${selectedDomain}`;
await fetch("/api/mappings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: fullEmail,
userId: createForm.notifyUserId,
}),
});
}
setShowCreateModal(false);
setCreateForm({ local_part: "", name: "", password: "", quota: 3072 });
setCreateForm({ local_part: "", name: "", password: "", quota: 3072, notifyUserId: "" });
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");
: (data?.error ?? "Mailcow bağlantısını veya veritabanını kontrol edin");
setFormError(String(msg));
}
} catch (error: any) {
console.error("Mailbox creation failed:", error);
setFormError(error.message);
}
});
};
@@ -333,6 +374,21 @@ export default function MailboxesPage() {
<input type="number" className="input" value={createForm.quota} min={100} max={102400}
onChange={(e) => setCreateForm({ ...createForm, quota: parseInt(e.target.value) || 3072 })} />
</div>
<div>
<label className="label">{dict.mailboxes.notifyUser || "Bildirim Gidecek Kullanıcı (TG)"}</label>
<select
className="input"
value={createForm.notifyUserId}
onChange={(e) => setCreateForm({ ...createForm, notifyUserId: e.target.value })}
>
<option value="">-- {dict.mailboxes.noNotify || "Bildirim Gönderme"} --</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name || u.email} {u.telegramId ? `(TG: ${u.telegramId})` : ""}
</option>
))}
</select>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-ghost" onClick={() => setShowCreateModal(false)}>{dict.mailboxes.cancel || "İptal"}</button>

View File

@@ -0,0 +1,212 @@
"use client";
import { useState, useEffect } from "react";
import { useDictionary } from "@/components/DictionaryContext";
interface User {
id: string;
name: string;
email: string;
}
interface Mapping {
id: string;
email: string;
userId: string;
user: User;
}
export default function MappingsPage() {
const [mappings, setMappings] = useState<Mapping[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [isModalOpen, setIsModalOpen] = useState(false);
// Form state
const [newEmail, setNewEmail] = useState("");
const [newUserId, setNewUserId] = useState("");
const [saving, setSaving] = useState(false);
const dict = useDictionary();
const fetchData = async () => {
setLoading(true);
try {
const [mRes, uRes] = await Promise.all([
fetch("/api/mappings"),
fetch("/api/users")
]);
const [mData, uData] = await Promise.all([mRes.json(), uRes.json()]);
setMappings(Array.isArray(mData) ? mData : []);
setUsers(Array.isArray(uData) ? uData : []);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const res = await fetch("/api/mappings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: newEmail, userId: newUserId })
});
if (res.ok) {
setIsModalOpen(false);
setNewEmail("");
setNewUserId("");
fetchData();
}
} catch (e) {
console.error(e);
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Bu eşleştirmeyi silmek istediğinize emin misiniz?")) return;
try {
const res = await fetch(`/api/mappings/${id}`, { method: "DELETE" });
if (res.ok) fetchData();
} catch (e) {
console.error(e);
}
};
const filtered = mappings.filter(m =>
m.email.toLowerCase().includes(search.toLowerCase()) ||
m.user.name.toLowerCase().includes(search.toLowerCase())
);
return (
<>
<div className="page-header">
<div>
<h1 className="page-title">{dict.mappings.title}</h1>
<p className="page-subtitle">{dict.mappings.subtitle}</p>
</div>
<button className="btn btn-primary" onClick={() => setIsModalOpen(true)}>
<PlusIcon />
{dict.mappings.addMapping}
</button>
</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={dict.mappings.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className="table-wrap">
{loading ? (
<div className="empty-state"><span className="spinner" /></div>
) : filtered.length === 0 ? (
<div className="empty-state">
<div className="empty-icon"><LinkIcon /></div>
<div style={{ fontWeight: 600 }}>{dict.mappings.noMappings}</div>
</div>
) : (
<table>
<thead>
<tr>
<th>{dict.mappings.email}</th>
<th>{dict.mappings.user}</th>
<th style={{ width: 80 }}></th>
</tr>
</thead>
<tbody>
{filtered.map((m) => (
<tr key={m.id}>
<td>
<div style={{ fontWeight: 500 }}>{m.email}</div>
</td>
<td>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div className="user-avatar" style={{ width: 24, height: 24, fontSize: 10 }}>
{m.user.name[0]?.toUpperCase()}
</div>
<span>{m.user.name}</span>
<span style={{ fontSize: 11, color: "var(--text-secondary)" }}>({m.user.email})</span>
</div>
</td>
<td>
<button className="btn btn-ghost" onClick={() => handleDelete(m.id)} style={{ color: "var(--error)" }}>
<TrashIcon />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{isModalOpen && (
<div className="modal-overlay">
<div className="modal-content" style={{ maxWidth: 450 }}>
<div className="modal-header">
<h2 className="modal-title">{dict.mappings.addMapping}</h2>
<button className="modal-close" onClick={() => setIsModalOpen(false)}>×</button>
</div>
<form onSubmit={handleAdd} className="form-group" style={{ padding: 20 }}>
<div>
<label className="label">{dict.mappings.email}</label>
<input
className="input"
placeholder="info@domain.com"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
required
/>
</div>
<div>
<label className="label">{dict.mappings.user}</label>
<select
className="input"
value={newUserId}
onChange={(e) => setNewUserId(e.target.value)}
required
>
<option value="">{dict.mappings.user} seçin...</option>
{users.map(u => (
<option key={u.id} value={u.id}>{u.name} ({u.email})</option>
))}
</select>
</div>
<div style={{ display: "flex", gap: 10, marginTop: 10 }}>
<button type="button" className="btn btn-ghost" style={{ flex: 1 }} onClick={() => setIsModalOpen(false)}>İptal</button>
<button type="submit" className="btn btn-primary" style={{ flex: 1 }} disabled={saving}>
{saving ? <span className="spinner" /> : "Kaydet"}
</button>
</div>
</form>
</div>
</div>
)}
</>
);
}
function PlusIcon() { return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" 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 LinkIcon() { return <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>; }
function TrashIcon() { return <svg width="14" height="14" 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>; }

View File

@@ -9,27 +9,108 @@ interface User {
email: string;
role: string;
domains: string[];
telegramId?: string;
password?: string;
}
export default function UsersPage() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
// Form state
const [formData, setFormData] = useState({
name: "",
email: "",
password: "",
role: "DOMAIN_ADMIN",
domains: "",
telegramId: ""
});
const [saving, setSaving] = useState(false);
const dict = useDictionary();
useEffect(() => {
fetch("/api/users")
.then((r) => r.json())
.then((data) => {
const fetchUsers = async () => {
setLoading(true);
try {
const res = await fetch("/api/users");
const data = await res.json();
setUsers(Array.isArray(data) ? data : []);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
})
.catch(() => setLoading(false));
}
};
useEffect(() => {
fetchUsers();
}, []);
const openAddModal = () => {
setEditingUser(null);
setFormData({ name: "", email: "", password: "", role: "DOMAIN_ADMIN", domains: "", telegramId: "" });
setIsModalOpen(true);
};
const openEditModal = (user: User) => {
setEditingUser(user);
setFormData({
name: user.name || "",
email: user.email,
password: user.password || "",
role: user.role,
domains: user.domains.join(", "),
telegramId: user.telegramId || ""
});
setIsModalOpen(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const url = editingUser ? `/api/users/${editingUser.id}` : "/api/users";
const method = editingUser ? "PATCH" : "POST";
const payload = {
...formData,
domains: formData.domains.split(",").map(d => d.trim()).filter(Boolean)
};
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (res.ok) {
setIsModalOpen(false);
fetchUsers();
}
} catch (e) {
console.error(e);
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Bu kullanıcıyı silmek istediğinize emin misiniz?")) return;
try {
const res = await fetch(`/api/users/${id}`, { method: "DELETE" });
if (res.ok) fetchUsers();
} catch (e) {
console.error(e);
}
};
const filtered = users.filter(
(u) =>
u.name.toLowerCase().includes(search.toLowerCase()) ||
u.name?.toLowerCase().includes(search.toLowerCase()) ||
u.email.toLowerCase().includes(search.toLowerCase())
);
@@ -37,35 +118,24 @@ export default function UsersPage() {
<>
<div className="page-header">
<div>
<h1 className="page-title">{dict.users.title || "Kullanıcılar"}</h1>
<p className="page-subtitle">{dict.users.subtitle || "Panel kullanıcıları .env dosyasından yönetilir"}</p>
<h1 className="page-title">{dict.users.title}</h1>
<p className="page-subtitle">{dict.users.subtitle}</p>
</div>
<button className="btn btn-primary" onClick={openAddModal}>
<PlusIcon />
{dict.users.addUser}
</button>
</div>
<div className="page-body">
{/* Info card */}
<div className="card" style={{ display: "flex", gap: 14, alignItems: "flex-start", border: "1px solid var(--accent-dim)", background: "var(--accent-dim)" }}>
<div style={{ color: "var(--accent-hover)", flexShrink: 0, paddingTop: 2 }}>
<InfoIcon />
</div>
<div>
<div style={{ fontWeight: 600, color: "var(--accent-hover)", marginBottom: 6 }}>{dict.users.info ? "Info" : "Kullanıcı yönetimi hakkında"}</div>
<div style={{ fontWeight: 600, color: "var(--accent-hover)", marginBottom: 6 }}>Kullanıcı Yönetimi</div>
<div style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.7 }}>
{dict.users.info || (
<>
Kullanıcılar <code style={{ background: "var(--bg)", padding: "1px 6px", borderRadius: 4, fontSize: 12 }}>.env</code> dosyasındaki{" "}
<code style={{ background: "var(--bg)", padding: "1px 6px", borderRadius: 4, fontSize: 12 }}>USER_0_*</code>,{" "}
<code style={{ background: "var(--bg)", padding: "1px 6px", borderRadius: 4, fontSize: 12 }}>USER_1_*</code> değişkenleriyle tanımlanır.
Yeni kullanıcı eklemek için .env dosyasını düzenleyip uygulamayı yeniden başlatın.
</>
)}
</div>
<div style={{ marginTop: 12, padding: "10px 14px", background: "var(--bg)", borderRadius: "var(--radius)", fontSize: 12, fontFamily: "monospace", color: "var(--text-secondary)", lineHeight: 2 }}>
USER_2_NAME=&quot;Ahmet Yılmaz&quot;<br />
USER_2_EMAIL=&quot;ahmet@ayristech.com&quot;<br />
USER_2_PASSWORD=&quot;güçlü-şifre&quot;<br />
USER_2_ROLE=&quot;DOMAIN_ADMIN&quot;<br />
USER_2_DOMAINS=&quot;yenidomain.com&quot;
{dict.users.info}
</div>
</div>
</div>
@@ -76,7 +146,7 @@ export default function UsersPage() {
<input
type="text"
className="input search-input"
placeholder={dict.users.searchPlaceholder || "İsim veya e-posta ara..."}
placeholder={dict.users.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
@@ -85,21 +155,21 @@ export default function UsersPage() {
<div className="table-wrap">
{loading ? (
<div className="empty-state">
<span className="spinner" style={{ width: 24, height: 24 }} />
</div>
<div className="empty-state"><span className="spinner" /></div>
) : filtered.length === 0 ? (
<div className="empty-state">
<div className="empty-icon"><UsersIcon /></div>
<div style={{ fontWeight: 600 }}>{dict.users.noUsers || "Kullanıcı bulunamadı"}</div>
<div style={{ fontWeight: 600 }}>{dict.users.noUsers}</div>
</div>
) : (
<table>
<thead>
<tr>
<th>{dict.users.username || "Kullanıcı"}</th>
<th>{dict.users.role || "Rol"}</th>
<th>{dict.users.domains || "İzin Verilen Domainler"}</th>
<th>{dict.users.username}</th>
<th>{dict.users.role}</th>
<th>{dict.users.domains}</th>
<th>Telegram ID</th>
<th style={{ width: 100 }}></th>
</tr>
</thead>
<tbody>
@@ -108,7 +178,7 @@ export default function UsersPage() {
<td>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div className="user-avatar" style={{ width: 32, height: 32, fontSize: 13 }}>
{u.name[0]?.toUpperCase()}
{u.name ? u.name[0]?.toUpperCase() : "?"}
</div>
<div>
<div style={{ fontWeight: 500 }}>{u.name}</div>
@@ -118,12 +188,12 @@ export default function UsersPage() {
</td>
<td>
<span className={`badge ${u.role === "SUPER_ADMIN" ? "badge-blue" : "badge-green"}`}>
{u.role === "SUPER_ADMIN" ? `${dict.users.superAdmin || "Süper Admin"}` : (dict.users.domainAdmin || "Domain Admin")}
{u.role === "SUPER_ADMIN" ? `${dict.users.superAdmin}` : dict.users.domainAdmin}
</span>
</td>
<td>
{u.domains.includes("*") ? (
<span className="badge badge-blue">{dict.users.allDomains || "Tüm domainler"}</span>
<span className="badge badge-blue">{dict.users.allDomains}</span>
) : (
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
{u.domains.map((d) => (
@@ -132,6 +202,15 @@ export default function UsersPage() {
</div>
)}
</td>
<td>
<code style={{ fontSize: 11 }}>{u.telegramId || "-"}</code>
</td>
<td>
<div style={{ display: "flex", gap: 4 }}>
<button className="btn btn-ghost" onClick={() => openEditModal(u)}><EditIcon /></button>
<button className="btn btn-ghost" onClick={() => handleDelete(u.id)} style={{ color: "var(--error)" }}><TrashIcon /></button>
</div>
</td>
</tr>
))}
</tbody>
@@ -139,10 +218,63 @@ export default function UsersPage() {
)}
</div>
</div>
{isModalOpen && (
<div className="modal-overlay">
<div className="modal-content" style={{ maxWidth: 500 }}>
<div className="modal-header">
<h2 className="modal-title">{editingUser ? dict.users.editUser : dict.users.addUser}</h2>
<button className="modal-close" onClick={() => setIsModalOpen(false)}>×</button>
</div>
<form onSubmit={handleSubmit} className="form-group" style={{ padding: 20 }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14 }}>
<div>
<label className="label">İsim</label>
<input className="input" value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} required />
</div>
<div>
<label className="label">E-posta</label>
<input className="input" type="email" value={formData.email} onChange={e => setFormData({...formData, email: e.target.value})} required />
</div>
</div>
<div>
<label className="label">Şifre {editingUser && "(Değiştirmek istemiyorsanız boş bırakın)"}</label>
<input className="input" type="password" value={formData.password} onChange={e => setFormData({...formData, password: e.target.value})} required={!editingUser} />
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14 }}>
<div>
<label className="label">Rol</label>
<select className="input" value={formData.role} onChange={e => setFormData({...formData, role: e.target.value})}>
<option value="SUPER_ADMIN">Süper Admin</option>
<option value="DOMAIN_ADMIN">Domain Admin</option>
</select>
</div>
<div>
<label className="label">Telegram ID</label>
<input className="input" placeholder="Örn: 5009005027" value={formData.telegramId} onChange={e => setFormData({...formData, telegramId: e.target.value})} />
</div>
</div>
<div>
<label className="label">İzinli Domainler (Virgülle ayırın, tümü için *)</label>
<input className="input" placeholder="domain1.com, domain2.com" value={formData.domains} onChange={e => setFormData({...formData, domains: e.target.value})} />
</div>
<div style={{ display: "flex", gap: 10, marginTop: 10 }}>
<button type="button" className="btn btn-ghost" style={{ flex: 1 }} onClick={() => setIsModalOpen(false)}>İptal</button>
<button type="submit" className="btn btn-primary" style={{ flex: 1 }} disabled={saving}>
{saving ? <span className="spinner" /> : "Kaydet"}
</button>
</div>
</form>
</div>
</div>
)}
</>
);
}
function PlusIcon() { return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" 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 UsersIcon() { return <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>; }
function UsersIcon() { return <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>; }
function InfoIcon() { return <svg width="16" height="16" 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 TrashIcon() { return <svg width="14" height="14" 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 EditIcon() { return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>; }

24
app/api/logs/route.ts Normal file
View File

@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
// GET /api/logs — list notification logs
export async function GET() {
const session = await auth();
if (!session || session.user.role !== "SUPER_ADMIN") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const logs = await prisma.notificationLog.findMany({
include: { user: true },
orderBy: { createdAt: "desc" },
take: 100, // Last 100 logs
});
return NextResponse.json(logs);
} catch (error: any) {
console.error("[API Logs] Error:", error.message);
return NextResponse.json({ error: "Tablo bulunamadı veya veritabanı hatası. Migration yapıldığından emin olun." }, { status: 500 });
}
}

View File

@@ -1,10 +1,12 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { getMailboxes, createMailbox } from "@/lib/mailcow";
import { getMailboxes, createMailbox, setupMailboxForwarding } from "@/lib/mailcow";
import { canAccessDomain } from "@/lib/users";
import { prisma } from "@/lib/prisma";
// GET /api/mailboxes?domain=example.com
export async function GET(req: NextRequest) {
// ... existing GET ...
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@@ -26,6 +28,7 @@ export async function POST(req: NextRequest) {
const body = await req.json();
const { local_part, domain, name, password, quota } = body;
const fullEmail = `${local_part}@${domain}`;
if (!local_part || !domain || !name || !password) {
return NextResponse.json({ error: "Eksik alan" }, { status: 400 });
@@ -35,6 +38,62 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Bu domaine erişim yetkiniz yok" }, { status: 403 });
}
// 1. Create Mailbox in Mailcow
const result = await createMailbox({ local_part, domain, name, password, quota });
return NextResponse.json(result.data, { status: result.ok ? 200 : 502 });
if (!result.ok) {
// Log failure to SystemLog
try {
await prisma.systemLog.create({
data: {
level: "ERROR",
message: `Mailbox creation failed: ${fullEmail}`,
details: JSON.stringify(result.data),
},
});
} catch (e) {
console.error("[SystemLog] Failed to log error", e);
}
return NextResponse.json(result.data, { status: 502 });
}
// 2. Automated "One-Click" Setup: Create Forwarding to Webhook
const webhookUrl = `${req.nextUrl.origin}/api/webhooks/mail`;
console.log(`[Setup] Setting up auto-forwarding for ${fullEmail} to ${webhookUrl}`);
const setupResult = await setupMailboxForwarding(fullEmail, webhookUrl);
if (!setupResult.ok) {
console.error(`[Setup] Failed to setup auto-forwarding for ${fullEmail}`);
try {
await prisma.systemLog.create({
data: {
level: "WARN",
message: `Auto-forwarding setup failed for ${fullEmail}`,
details: JSON.stringify(setupResult.data),
},
});
} catch (e) {
console.error("[SystemLog] Failed to log warning", e);
}
// We still return success for mailbox creation, but maybe with a warning header/prop
return NextResponse.json({
...result.data,
setup_warning: "Bildirim kurulumu otomatik yapılamadı, lütfen manuel kontrol edin."
});
}
// Log success
try {
await prisma.systemLog.create({
data: {
level: "INFO",
message: `Mailbox created and notification setup completed: ${fullEmail}`,
},
});
} catch (e) {
console.error("[SystemLog] Failed to log success", e);
}
return NextResponse.json(result.data);
}

View File

@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
// DELETE /api/mappings/[id] — delete a mapping
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
const { id } = await params;
if (!session || session.user.role !== "SUPER_ADMIN") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
await prisma.mailboxMapping.delete({
where: { id },
});
return NextResponse.json({ status: "ok" });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

43
app/api/mappings/route.ts Normal file
View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
// GET /api/mappings — list all mappings
export async function GET() {
const session = await auth();
if (!session || session.user.role !== "SUPER_ADMIN") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const mappings = await prisma.mailboxMapping.findMany({
include: { user: true },
orderBy: { createdAt: "desc" },
});
return NextResponse.json(mappings);
}
// POST /api/mappings — create a new mapping
export async function POST(req: NextRequest) {
const session = await auth();
if (!session || session.user.role !== "SUPER_ADMIN") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const body = await req.json();
const { email, userId } = body;
const mapping = await prisma.mailboxMapping.create({
data: {
email: email.toLowerCase().trim(),
userId,
},
include: { user: true },
});
return NextResponse.json(mapping);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
// PATCH /api/users/[id] — update a user
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
const { id } = await params;
if (!session || session.user.role !== "SUPER_ADMIN") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const body = await req.json();
const { name, email, password, role, domains, telegramId } = body;
const user = await prisma.user.update({
where: { id },
data: {
name,
email: email?.toLowerCase(),
password,
role,
domains,
telegramId,
},
});
return NextResponse.json(user);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
// DELETE /api/users/[id] — delete a user
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
const { id } = await params;
if (!session || session.user.role !== "SUPER_ADMIN") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
await prisma.user.delete({
where: { id },
});
return NextResponse.json({ status: "ok" });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -1,22 +1,45 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { getUsers } from "@/lib/users";
import { prisma } from "@/lib/prisma";
// GET /api/users — super admin only, lists env-defined users (no passwords)
// GET /api/users — list all users
export async function GET() {
const session = await auth();
if (!session || session.user.role !== "SUPER_ADMIN") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const allUsers = await getUsers();
const users = allUsers.map(({ id, name, email, role, domains }) => ({
id,
name,
email,
role,
domains,
}));
const users = await prisma.user.findMany({
orderBy: { createdAt: "asc" },
});
return NextResponse.json(users);
}
// POST /api/users — create a new user
export async function POST(req: NextRequest) {
const session = await auth();
if (!session || session.user.role !== "SUPER_ADMIN") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const body = await req.json();
const { name, email, password, role, domains, telegramId } = body;
const user = await prisma.user.create({
data: {
name,
email: email.toLowerCase(),
password,
role: role || "DOMAIN_ADMIN",
domains: domains || [],
telegramId,
},
});
return NextResponse.json(user);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -9,15 +9,20 @@ import { prisma } from "@/lib/prisma";
*/
export async function POST(req: NextRequest) {
let aliciMail = "Bilinmiyor";
let sender = "Bilinmiyor";
let subject = "(Konu Yok)";
try {
const data = await req.json();
console.log("[Mail Webhook] Gelen Payload:", JSON.stringify(data));
// Extract basic info from the incoming payload
const aliciMail = (data.to || data.rcpt || "").toLowerCase().trim();
const sender = data.from || "Bilinmiyor";
const subject = data.subject || "(Konu Yok)";
// Extract basic info from the incoming payload (Mailcow handles these fields)
aliciMail = (data.to || data.rcpt || "").toLowerCase().trim();
sender = data.from || "Bilinmiyor";
subject = data.subject || "(Konu Yok)";
console.log(`[Mail Webhook] Yeni mail geldi: ${sender} -> ${aliciMail}`);
console.log(`[Mail Webhook] İşleniyor: ${sender} -> ${aliciMail}`);
// 1. Find mapping in database
const mapping = await prisma.mailboxMapping.findUnique({
@@ -31,9 +36,12 @@ export async function POST(req: NextRequest) {
if (targetChatId && process.env.TELEGRAM_BOT_TOKEN) {
const message = `🔔 *Yeni Mail Geldi!*\n\n📧 *Alıcı:* ${aliciMail}\n👤 *Gönderen:* ${sender}\n📝 *Konu:* ${subject}`;
const telegramUrl = `https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`;
let status = "SENT";
let errorDetail = null;
try {
const res = await fetch(telegramUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -45,19 +53,72 @@ export async function POST(req: NextRequest) {
});
if (!res.ok) {
const errorText = await res.text();
console.error(`[Mail Webhook] Telegram API hatası: ${res.status} ${errorText}`);
} else {
console.log(`[Webhook] Bildirim ${user.email} kullanıcısına (ID: ${targetChatId}) gönderildi.`);
status = "FAILED";
errorDetail = `Telegram API Error: ${res.status} ${await res.text()}`;
}
} catch (err: any) {
status = "FAILED";
errorDetail = `Network Error: ${err.message}`;
}
// Log successful/failed delivery
await prisma.notificationLog.create({
data: {
mailbox: aliciMail,
sender,
subject,
status,
error: errorDetail,
userId: user.id,
},
});
} else {
// Log that user was found but notification skipped
await prisma.notificationLog.create({
data: {
mailbox: aliciMail,
sender,
subject,
status: "FAILED",
error: !process.env.TELEGRAM_BOT_TOKEN ? "Bot token missing" : "User has no Telegram ID",
userId: user.id,
},
});
}
} else {
console.log(`[Webhook] Sahibi bilinmeyen veya eşleşmeyen mail: ${aliciMail}`);
// Log unmapped mail
await prisma.notificationLog.create({
data: {
mailbox: aliciMail,
sender,
subject,
status: "FAILED",
error: "No user mapping found for this email",
},
});
}
return NextResponse.json({ status: "ok" });
} catch (error: any) {
console.error(`[Mail Webhook] Hata: ${error.message}`);
// Attempt to log the fatal error if we have enough info
try {
await prisma.notificationLog.create({
data: {
mailbox: aliciMail,
sender,
subject,
status: "FAILED",
error: `Fatal Error: ${error.message}`,
},
});
} catch (dbErr) {
console.error("[Mail Webhook] Could not even log the error to DB");
}
return NextResponse.json({ error: "İşlem başarısız" }, { status: 500 });
}
}

View File

@@ -14,6 +14,8 @@
"mailboxes": "Mailboxes",
"mailClient": "Mail Client",
"users": "User Management",
"mappings": "Mappings",
"logs": "Notification Logs",
"logout": "Log Out",
"general": "GENERAL",
"management": "MANAGEMENT",
@@ -27,7 +29,7 @@
"mailboxes": "Mailboxes",
"aliases": "Aliases",
"users": "Defined Users",
"usersSub": "Users are managed from .env",
"usersSub": "Users managed via database",
"domainStatus": "Domain Status",
"domain": "Domain",
"quotaUsage": "Quota Usage",
@@ -40,7 +42,7 @@
"manageMailboxes": "Mailboxes",
"manageMailboxesDesc": "Create new account, change password, delete",
"manageUsers": "Users",
"manageUsersDesc": "View defined panel users from .env"
"manageUsersDesc": "Manage and authorize panel users"
},
"domains": {
"title": "Domains",
@@ -61,16 +63,41 @@
"tryDiffSearch": "Try a different search term"
},
"users": {
"title": "System Users",
"subtitle": "Admin users defined via .env file",
"info": "User management is done securely via the environment variables (.env). This panel is read-only.",
"username": "Username",
"name": "Name",
"title": "User Management",
"subtitle": "Manage panel access permissions and notification settings",
"addUser": "Add User",
"editUser": "Edit User",
"username": "User",
"role": "Role",
"domains": "Authorized Domains",
"domains": "Allowed Domains",
"superAdmin": "Super Admin",
"domainAdmin": "Domain Admin",
"allDomains": "All Domains"
"allDomains": "All domains",
"noUsers": "No users found",
"searchPlaceholder": "Search name or email...",
"info": "Users are now stored in the database. You can update permissions and notification settings here."
},
"mappings": {
"title": "Mail Mappings",
"subtitle": "Manage which user receives notifications for incoming mails",
"addMapping": "Add Mapping",
"email": "Email Address",
"user": "User to Notify",
"noMappings": "No mappings found",
"searchPlaceholder": "Search email address..."
},
"logs": {
"title": "Notification Logs",
"subtitle": "Status and details of recently sent notifications",
"mailbox": "Recipient Mail",
"sender": "Sender",
"subject": "Subject",
"status": "Status",
"date": "Date",
"error": "Error Detail",
"noLogs": "No log records found yet",
"sent": "SENT",
"failed": "FAILED"
},
"mailboxes": {
"title": "Mailboxes",
@@ -100,6 +127,8 @@
"quotaMb": "Quota (MB)",
"cancel": "Cancel",
"create": "Create",
"notifyUser": "Notify User (TG)",
"noNotify": "No Notifications",
"newPasswordFor": "New password for",
"update": "Update",
"connectionInfo": "Client Connection Info",

View File

@@ -14,6 +14,8 @@
"mailboxes": "Mail Hesapları",
"mailClient": "Mail İstemcisi",
"users": "Kullanıcı Yönetimi",
"mappings": "Eşleştirmeler",
"logs": "Bildirim Logları",
"logout": ıkış Yap",
"general": "GENEL",
"management": "YÖNETİM",
@@ -27,7 +29,7 @@
"mailboxes": "Mail Kutuları",
"aliases": "Alias",
"users": "Tanımlı Kullanıcı",
"usersSub": "Kullanıcılar .env'den yönetilir",
"usersSub": "Kullanıcılar veritabanından yönetilir",
"domainStatus": "Domain Durumu",
"domain": "Domain",
"quotaUsage": "Kota Kullanımı",
@@ -40,7 +42,7 @@
"manageMailboxes": "Mail Hesapları",
"manageMailboxesDesc": "Yeni hesap oluştur, şifre değiştir, sil",
"manageUsers": "Kullanıcılar",
"manageUsersDesc": ".env'den tanımlı panel kullanıcılarını görüntüle"
"manageUsersDesc": "Panel kullanıcılarını yönet ve yetkilendir"
},
"domains": {
"title": "Domainler",
@@ -61,16 +63,41 @@
"tryDiffSearch": "Farklı bir arama yapın"
},
"users": {
"title": "Sistem Kullanıcıları",
"subtitle": ".env dosyası üzerinden tanımlanmış yetkili kullanıcılar",
"info": "Kullanıcı yönetimi güvenlik nedeniyle sadece çevresel değişkenler (.env) üzerinden yapılmaktadır. Bu ekran salt okunurdur.",
"username": "Kullanıcı Adı",
"name": "Ad Soyad",
"title": "Kullanıcı Yönetimi",
"subtitle": "Panel erişim yetkilerini ve bildirim ayarlarını yönetin",
"addUser": "Yeni Kullanıcı",
"editUser": "Kullanıcıyı Düzenle",
"username": "Kullanıcı",
"role": "Rol",
"domains": "Yetkili Domainler",
"domains": "İzin Verilen Domainler",
"superAdmin": "Süper Admin",
"domainAdmin": "Domain Admin",
"allDomains": "Tüm Domainler"
"allDomains": "Tüm domainler",
"noUsers": "Kullanıcı bulunamadı",
"searchPlaceholder": "İsim veya e-posta ara...",
"info": "Kullanıcılar artık veritabanında saklanmaktadır. Buradan yetki ve bildirim ayarlarını güncelleyebilirsiniz."
},
"mappings": {
"title": "Mail Eşleştirmeleri",
"subtitle": "Gelen maillerin hangi kullanıcıya bildirileceğini yönetin",
"addMapping": "Yeni Eşleştirme",
"email": "Mail Adresi",
"user": "Bildirilecek Kullanıcı",
"noMappings": "Eşleştirme bulunamadı",
"searchPlaceholder": "Mail adresi ara..."
},
"logs": {
"title": "Bildirim Logları",
"subtitle": "Son gönderilen bildirimlerin durumu ve detayları",
"mailbox": "Alıcı Mail",
"sender": "Gönderen",
"subject": "Konu",
"status": "Durum",
"date": "Tarih",
"error": "Hata Detayı",
"noLogs": "Henüz log kaydı bulunmuyor",
"sent": "GÖNDERİLDİ",
"failed": "HATA"
},
"mailboxes": {
"title": "Mail Hesapları",
@@ -100,6 +127,8 @@
"quotaMb": "Kota (MB)",
"cancel": "İptal",
"create": "Oluştur",
"notifyUser": "Bildirim Gidecek Kullanıcı (TG)",
"noNotify": "Bildirim Gönderme",
"newPasswordFor": "için yeni şifre",
"update": "Güncelle",
"connectionInfo": "İstemci Bağlantı Bilgileri",

View File

@@ -25,7 +25,9 @@ export default function Sidebar({ dict, lang }: { dict: any; lang: string }) {
items: [
{ href: `/${lang}/dashboard/domains`, label: dict.domains?.title || "Domainler", icon: GlobeIcon, roles: ["SUPER_ADMIN"] },
{ href: `/${lang}/dashboard/users`, label: dict.sidebar?.users || "Kullanıcılar", icon: UsersIcon, roles: ["SUPER_ADMIN"] },
{ href: `/${lang}/dashboard/mappings`, label: dict.sidebar?.mappings || "Eşleştirmeler", icon: LinkIcon, roles: ["SUPER_ADMIN"] },
{ href: `/${lang}/dashboard/mailboxes`, label: dict.sidebar?.mailboxes || "Mail Hesapları", icon: MailIcon, roles: ["SUPER_ADMIN", "DOMAIN_ADMIN"] },
{ href: `/${lang}/dashboard/logs`, label: dict.sidebar?.logs || "Loglar", icon: ListIcon, roles: ["SUPER_ADMIN"] },
],
},
];
@@ -153,3 +155,24 @@ function LogOutIcon() {
</svg>
);
}
function LinkIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
);
}
function ListIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="8" x2="21" y1="6" y2="6" />
<line x1="8" x2="21" y1="12" y2="12" />
<line x1="8" x2="21" y1="18" y2="18" />
<line x1="3" x2="3.01" y1="6" y2="6" />
<line x1="3" x2="3.01" y1="12" y2="12" />
<line x1="3" x2="3.01" y1="18" y2="18" />
</svg>
);
}

View File

@@ -28,7 +28,8 @@ async function mfetch(path: string, options: RequestInit = {}) {
cache: "no-store",
});
if (!res.ok) {
const text = await res.text();
const clone = res.clone();
const text = await clone.text();
console.error(`[Mailcow API] Error ${res.status}: ${text}`);
}
return res;
@@ -228,3 +229,23 @@ export async function getDKIM(domain: string) {
if (!res.ok) return null;
return res.json();
}
// ─── Forwarding / Webhook Setup ──────────────────────────────
// Automates the "one-click" configuration of mailbox notifications
export async function setupMailboxForwarding(address: string, webhookUrl: string) {
// Check if webhookUrl is localhost and warn in console
if (webhookUrl.includes("localhost")) {
console.warn(`[Setup] WARNING: Using localhost for webhook (${webhookUrl}). Mailcow will not be able to reach this!`);
}
const body = {
address: address,
goto: webhookUrl,
active: 1,
sogo_visible: 0,
};
const res = await mfetch("/add/alias", { method: "POST", body: JSON.stringify(body) });
const data = await res.json();
return { ok: res.ok, data };
}

View File

@@ -1,19 +1,6 @@
/**
* lib/users.ts
* Reads user config from environment variables — no database needed.
*
* .env format:
* USER_0_NAME="Mustafa Ayris"
* USER_0_EMAIL="mustafa@ayristech.com"
* USER_0_PASSWORD="mustafa123"
* USER_0_ROLE="SUPER_ADMIN" // or "DOMAIN_ADMIN"
* USER_0_DOMAINS="*" // "*" for all, or "domain1.com,domain2.com"
*
* USER_1_NAME="Emina Karabudak"
* USER_1_EMAIL="emina@ayristech.com"
* USER_1_PASSWORD="emina123"
* USER_1_ROLE="DOMAIN_ADMIN"
* USER_1_DOMAINS="aveminakarabudak.com"
* Manages panel users via PostgreSQL database.
*/
import { prisma } from "./prisma";

View File

@@ -20,6 +20,7 @@ model User {
telegramId String?
mailboxMappings MailboxMapping[]
notificationConfigs NotificationConfig[]
notificationLogs NotificationLog[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -41,3 +42,23 @@ model NotificationConfig {
active Boolean @default(true)
createdAt DateTime @default(now())
}
model NotificationLog {
id String @id @default(cuid())
mailbox String // Bildirim gelen mail adresi
sender String? // Gönderen kişi
subject String? // Konu
status String // "SENT", "FAILED"
error String? // Hata varsa detayı
userId String? // Hangi kullanıcıya bildirim gittiği
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
}
model SystemLog {
id String @id @default(cuid())
level String // "INFO", "WARN", "ERROR"
message String
details String?
createdAt DateTime @default(now())
}

View File

@@ -1,56 +1,29 @@
import { prisma } from "../lib/prisma";
import { getUsers } from "../lib/users";
import "dotenv/config";
async function main() {
console.log("Seeding database...");
// 1. Migrate Users
const users = await getUsers();
for (const user of users) {
console.log(`Migrating user: ${user.email}`);
await prisma.user.upsert({
where: { email: user.email },
update: {
name: user.name,
password: user.password,
role: user.role,
domains: user.domains,
telegramId: user.telegramId,
},
create: {
email: user.email,
name: user.name,
password: user.password,
role: user.role,
domains: user.domains,
telegramId: user.telegramId,
// Create a default super admin if none exists
const adminEmail = "admin@ayris.tech";
const existingAdmin = await prisma.user.findUnique({
where: { email: adminEmail }
});
if (!existingAdmin) {
console.log(`Creating default admin: ${adminEmail}`);
await prisma.user.create({
data: {
email: adminEmail,
name: "System Admin",
password: "admin123", // Should be changed immediately
role: "SUPER_ADMIN",
domains: ["*"],
},
});
}
// 2. Migrate Mailbox Mappings
const mappingsRaw = process.env.MAIL_USER_MAPPINGS || "{}";
try {
const mappings = JSON.parse(mappingsRaw);
for (const [email, userKey] of Object.entries(mappings)) {
const userIndex = parseInt((userKey as string).replace("USER_", ""));
const userEmail = process.env[`USER_${userIndex}_EMAIL`];
if (userEmail) {
const dbUser = await prisma.user.findUnique({ where: { email: userEmail } });
if (dbUser) {
console.log(`Creating mapping: ${email} -> ${userEmail}`);
await prisma.mailboxMapping.upsert({
where: { email },
update: { userId: dbUser.id },
create: { email, userId: dbUser.id },
});
}
}
}
} catch (e) {
console.error("Mapping migration failed:", e);
} else {
console.log("Admin user already exists.");
}
console.log("Seeding complete.");