Implement database migration, notification logs, and one-click Mailcow setup
This commit is contained in:
131
app/[lang]/dashboard/logs/page.tsx
Normal file
131
app/[lang]/dashboard/logs/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,21 +79,46 @@ export default function MailboxesPage() {
|
||||
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));
|
||||
try {
|
||||
const res = await fetch("/api/mailboxes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...createForm, domain: selectedDomain }),
|
||||
});
|
||||
|
||||
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, 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ı 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>
|
||||
|
||||
212
app/[lang]/dashboard/mappings/page.tsx
Normal file
212
app/[lang]/dashboard/mappings/page.tsx
Normal 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>; }
|
||||
@@ -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();
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/users")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setUsers(Array.isArray(data) ? data : []);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
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="Ahmet Yılmaz"<br />
|
||||
USER_2_EMAIL="ahmet@ayristech.com"<br />
|
||||
USER_2_PASSWORD="güçlü-şifre"<br />
|
||||
USER_2_ROLE="DOMAIN_ADMIN"<br />
|
||||
USER_2_DOMAINS="yenidomain.com"
|
||||
{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
24
app/api/logs/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
23
app/api/mappings/[id]/route.ts
Normal file
23
app/api/mappings/[id]/route.ts
Normal 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
43
app/api/mappings/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
54
app/api/users/[id]/route.ts
Normal file
54
app/api/users/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,33 +36,89 @@ 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`;
|
||||
|
||||
const res = await fetch(telegramUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
chat_id: targetChatId,
|
||||
text: message,
|
||||
parse_mode: "Markdown",
|
||||
}),
|
||||
});
|
||||
let status = "SENT";
|
||||
let errorDetail = null;
|
||||
|
||||
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.`);
|
||||
try {
|
||||
const res = await fetch(telegramUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
chat_id: targetChatId,
|
||||
text: message,
|
||||
parse_mode: "Markdown",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
15
lib/users.ts
15
lib/users.ts
@@ -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";
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
|
||||
Reference in New Issue
Block a user