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;
|
domain_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
telegramId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function MailboxesPage() {
|
export default function MailboxesPage() {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const [domains, setDomains] = useState<Domain[]>([]);
|
const [domains, setDomains] = useState<Domain[]>([]);
|
||||||
@@ -30,12 +37,14 @@ export default function MailboxesPage() {
|
|||||||
const [showInfoModal, setShowInfoModal] = useState<string | null>(null);
|
const [showInfoModal, setShowInfoModal] = useState<string | null>(null);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [search, setSearch] = useState("");
|
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 [newPassword, setNewPassword] = useState("");
|
||||||
const [formError, setFormError] = useState("");
|
const [formError, setFormError] = useState("");
|
||||||
const dict = useDictionary();
|
const dict = useDictionary();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Fetch domains
|
||||||
fetch("/api/domains")
|
fetch("/api/domains")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data: Domain[]) => {
|
.then((data: Domain[]) => {
|
||||||
@@ -44,6 +53,13 @@ export default function MailboxesPage() {
|
|||||||
setSelectedDomain(data[0].domain_name);
|
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) => {
|
const fetchMailboxes = useCallback(async (domain: string) => {
|
||||||
@@ -63,22 +79,47 @@ export default function MailboxesPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFormError("");
|
setFormError("");
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
const res = await fetch("/api/mailboxes", {
|
const res = await fetch("/api/mailboxes", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ ...createForm, domain: selectedDomain }),
|
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 (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);
|
setShowCreateModal(false);
|
||||||
setCreateForm({ local_part: "", name: "", password: "", quota: 3072 });
|
setCreateForm({ local_part: "", name: "", password: "", quota: 3072, notifyUserId: "" });
|
||||||
fetchMailboxes(selectedDomain);
|
fetchMailboxes(selectedDomain);
|
||||||
} else {
|
} else {
|
||||||
const msg = Array.isArray(data)
|
const msg = Array.isArray(data)
|
||||||
? data.map((d: { msg?: unknown }) => JSON.stringify(d.msg)).join(", ")
|
? 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));
|
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}
|
<input type="number" className="input" value={createForm.quota} min={100} max={102400}
|
||||||
onChange={(e) => setCreateForm({ ...createForm, quota: parseInt(e.target.value) || 3072 })} />
|
onChange={(e) => setCreateForm({ ...createForm, quota: parseInt(e.target.value) || 3072 })} />
|
||||||
</div>
|
</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>
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
<button type="button" className="btn btn-ghost" onClick={() => setShowCreateModal(false)}>{dict.mailboxes.cancel || "İptal"}</button>
|
<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;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
domains: string[];
|
domains: string[];
|
||||||
|
telegramId?: string;
|
||||||
|
password?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [search, setSearch] = useState("");
|
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 dict = useDictionary();
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchUsers = async () => {
|
||||||
fetch("/api/users")
|
setLoading(true);
|
||||||
.then((r) => r.json())
|
try {
|
||||||
.then((data) => {
|
const res = await fetch("/api/users");
|
||||||
|
const data = await res.json();
|
||||||
setUsers(Array.isArray(data) ? data : []);
|
setUsers(Array.isArray(data) ? data : []);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
setLoading(false);
|
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(
|
const filtered = users.filter(
|
||||||
(u) =>
|
(u) =>
|
||||||
u.name.toLowerCase().includes(search.toLowerCase()) ||
|
u.name?.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
u.email.toLowerCase().includes(search.toLowerCase())
|
u.email.toLowerCase().includes(search.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -37,35 +118,24 @@ export default function UsersPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">{dict.users.title || "Kullanıcılar"}</h1>
|
<h1 className="page-title">{dict.users.title}</h1>
|
||||||
<p className="page-subtitle">{dict.users.subtitle || "Panel kullanıcıları .env dosyasından yönetilir"}</p>
|
<p className="page-subtitle">{dict.users.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button className="btn btn-primary" onClick={openAddModal}>
|
||||||
|
<PlusIcon />
|
||||||
|
{dict.users.addUser}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="page-body">
|
<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 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 }}>
|
<div style={{ color: "var(--accent-hover)", flexShrink: 0, paddingTop: 2 }}>
|
||||||
<InfoIcon />
|
<InfoIcon />
|
||||||
</div>
|
</div>
|
||||||
<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 }}>
|
<div style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.7 }}>
|
||||||
{dict.users.info || (
|
{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"
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,7 +146,7 @@ export default function UsersPage() {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="input search-input"
|
className="input search-input"
|
||||||
placeholder={dict.users.searchPlaceholder || "İsim veya e-posta ara..."}
|
placeholder={dict.users.searchPlaceholder}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -85,21 +155,21 @@ export default function UsersPage() {
|
|||||||
|
|
||||||
<div className="table-wrap">
|
<div className="table-wrap">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state"><span className="spinner" /></div>
|
||||||
<span className="spinner" style={{ width: 24, height: 24 }} />
|
|
||||||
</div>
|
|
||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<div className="empty-icon"><UsersIcon /></div>
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{dict.users.username || "Kullanıcı"}</th>
|
<th>{dict.users.username}</th>
|
||||||
<th>{dict.users.role || "Rol"}</th>
|
<th>{dict.users.role}</th>
|
||||||
<th>{dict.users.domains || "İzin Verilen Domainler"}</th>
|
<th>{dict.users.domains}</th>
|
||||||
|
<th>Telegram ID</th>
|
||||||
|
<th style={{ width: 100 }}></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -108,7 +178,7 @@ export default function UsersPage() {
|
|||||||
<td>
|
<td>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
<div className="user-avatar" style={{ width: 32, height: 32, fontSize: 13 }}>
|
<div className="user-avatar" style={{ width: 32, height: 32, fontSize: 13 }}>
|
||||||
{u.name[0]?.toUpperCase()}
|
{u.name ? u.name[0]?.toUpperCase() : "?"}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 500 }}>{u.name}</div>
|
<div style={{ fontWeight: 500 }}>{u.name}</div>
|
||||||
@@ -118,12 +188,12 @@ export default function UsersPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`badge ${u.role === "SUPER_ADMIN" ? "badge-blue" : "badge-green"}`}>
|
<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>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{u.domains.includes("*") ? (
|
{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 }}>
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
|
||||||
{u.domains.map((d) => (
|
{u.domains.map((d) => (
|
||||||
@@ -132,6 +202,15 @@ export default function UsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -139,10 +218,63 @@ export default function UsersPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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 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 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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { getMailboxes, createMailbox } from "@/lib/mailcow";
|
import { getMailboxes, createMailbox, setupMailboxForwarding } from "@/lib/mailcow";
|
||||||
import { canAccessDomain } from "@/lib/users";
|
import { canAccessDomain } from "@/lib/users";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
// GET /api/mailboxes?domain=example.com
|
// GET /api/mailboxes?domain=example.com
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
// ... existing GET ...
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
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 body = await req.json();
|
||||||
const { local_part, domain, name, password, quota } = body;
|
const { local_part, domain, name, password, quota } = body;
|
||||||
|
const fullEmail = `${local_part}@${domain}`;
|
||||||
|
|
||||||
if (!local_part || !domain || !name || !password) {
|
if (!local_part || !domain || !name || !password) {
|
||||||
return NextResponse.json({ error: "Eksik alan" }, { status: 400 });
|
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 });
|
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 });
|
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 { 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() {
|
export async function GET() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session || session.user.role !== "SUPER_ADMIN") {
|
if (!session || session.user.role !== "SUPER_ADMIN") {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const allUsers = await getUsers();
|
const users = await prisma.user.findMany({
|
||||||
const users = allUsers.map(({ id, name, email, role, domains }) => ({
|
orderBy: { createdAt: "asc" },
|
||||||
id,
|
});
|
||||||
name,
|
|
||||||
email,
|
|
||||||
role,
|
|
||||||
domains,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return NextResponse.json(users);
|
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) {
|
export async function POST(req: NextRequest) {
|
||||||
|
let aliciMail = "Bilinmiyor";
|
||||||
|
let sender = "Bilinmiyor";
|
||||||
|
let subject = "(Konu Yok)";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await req.json();
|
const data = await req.json();
|
||||||
|
console.log("[Mail Webhook] Gelen Payload:", JSON.stringify(data));
|
||||||
|
|
||||||
// Extract basic info from the incoming payload
|
// Extract basic info from the incoming payload (Mailcow handles these fields)
|
||||||
const aliciMail = (data.to || data.rcpt || "").toLowerCase().trim();
|
aliciMail = (data.to || data.rcpt || "").toLowerCase().trim();
|
||||||
const sender = data.from || "Bilinmiyor";
|
sender = data.from || "Bilinmiyor";
|
||||||
const subject = data.subject || "(Konu Yok)";
|
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
|
// 1. Find mapping in database
|
||||||
const mapping = await prisma.mailboxMapping.findUnique({
|
const mapping = await prisma.mailboxMapping.findUnique({
|
||||||
@@ -31,9 +36,12 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
if (targetChatId && process.env.TELEGRAM_BOT_TOKEN) {
|
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 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 telegramUrl = `https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`;
|
||||||
|
|
||||||
|
let status = "SENT";
|
||||||
|
let errorDetail = null;
|
||||||
|
|
||||||
|
try {
|
||||||
const res = await fetch(telegramUrl, {
|
const res = await fetch(telegramUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -45,19 +53,72 @@ export async function POST(req: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errorText = await res.text();
|
status = "FAILED";
|
||||||
console.error(`[Mail Webhook] Telegram API hatası: ${res.status} ${errorText}`);
|
errorDetail = `Telegram API Error: ${res.status} ${await res.text()}`;
|
||||||
} else {
|
|
||||||
console.log(`[Webhook] Bildirim ${user.email} kullanıcısına (ID: ${targetChatId}) gönderildi.`);
|
|
||||||
}
|
}
|
||||||
|
} 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 {
|
} else {
|
||||||
console.log(`[Webhook] Sahibi bilinmeyen veya eşleşmeyen mail: ${aliciMail}`);
|
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" });
|
return NextResponse.json({ status: "ok" });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`[Mail Webhook] Hata: ${error.message}`);
|
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 });
|
return NextResponse.json({ error: "İşlem başarısız" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
"mailboxes": "Mailboxes",
|
"mailboxes": "Mailboxes",
|
||||||
"mailClient": "Mail Client",
|
"mailClient": "Mail Client",
|
||||||
"users": "User Management",
|
"users": "User Management",
|
||||||
|
"mappings": "Mappings",
|
||||||
|
"logs": "Notification Logs",
|
||||||
"logout": "Log Out",
|
"logout": "Log Out",
|
||||||
"general": "GENERAL",
|
"general": "GENERAL",
|
||||||
"management": "MANAGEMENT",
|
"management": "MANAGEMENT",
|
||||||
@@ -27,7 +29,7 @@
|
|||||||
"mailboxes": "Mailboxes",
|
"mailboxes": "Mailboxes",
|
||||||
"aliases": "Aliases",
|
"aliases": "Aliases",
|
||||||
"users": "Defined Users",
|
"users": "Defined Users",
|
||||||
"usersSub": "Users are managed from .env",
|
"usersSub": "Users managed via database",
|
||||||
"domainStatus": "Domain Status",
|
"domainStatus": "Domain Status",
|
||||||
"domain": "Domain",
|
"domain": "Domain",
|
||||||
"quotaUsage": "Quota Usage",
|
"quotaUsage": "Quota Usage",
|
||||||
@@ -40,7 +42,7 @@
|
|||||||
"manageMailboxes": "Mailboxes",
|
"manageMailboxes": "Mailboxes",
|
||||||
"manageMailboxesDesc": "Create new account, change password, delete",
|
"manageMailboxesDesc": "Create new account, change password, delete",
|
||||||
"manageUsers": "Users",
|
"manageUsers": "Users",
|
||||||
"manageUsersDesc": "View defined panel users from .env"
|
"manageUsersDesc": "Manage and authorize panel users"
|
||||||
},
|
},
|
||||||
"domains": {
|
"domains": {
|
||||||
"title": "Domains",
|
"title": "Domains",
|
||||||
@@ -61,16 +63,41 @@
|
|||||||
"tryDiffSearch": "Try a different search term"
|
"tryDiffSearch": "Try a different search term"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"title": "System Users",
|
"title": "User Management",
|
||||||
"subtitle": "Admin users defined via .env file",
|
"subtitle": "Manage panel access permissions and notification settings",
|
||||||
"info": "User management is done securely via the environment variables (.env). This panel is read-only.",
|
"addUser": "Add User",
|
||||||
"username": "Username",
|
"editUser": "Edit User",
|
||||||
"name": "Name",
|
"username": "User",
|
||||||
"role": "Role",
|
"role": "Role",
|
||||||
"domains": "Authorized Domains",
|
"domains": "Allowed Domains",
|
||||||
"superAdmin": "Super Admin",
|
"superAdmin": "Super Admin",
|
||||||
"domainAdmin": "Domain 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": {
|
"mailboxes": {
|
||||||
"title": "Mailboxes",
|
"title": "Mailboxes",
|
||||||
@@ -100,6 +127,8 @@
|
|||||||
"quotaMb": "Quota (MB)",
|
"quotaMb": "Quota (MB)",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
|
"notifyUser": "Notify User (TG)",
|
||||||
|
"noNotify": "No Notifications",
|
||||||
"newPasswordFor": "New password for",
|
"newPasswordFor": "New password for",
|
||||||
"update": "Update",
|
"update": "Update",
|
||||||
"connectionInfo": "Client Connection Info",
|
"connectionInfo": "Client Connection Info",
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
"mailboxes": "Mail Hesapları",
|
"mailboxes": "Mail Hesapları",
|
||||||
"mailClient": "Mail İstemcisi",
|
"mailClient": "Mail İstemcisi",
|
||||||
"users": "Kullanıcı Yönetimi",
|
"users": "Kullanıcı Yönetimi",
|
||||||
|
"mappings": "Eşleştirmeler",
|
||||||
|
"logs": "Bildirim Logları",
|
||||||
"logout": "Çıkış Yap",
|
"logout": "Çıkış Yap",
|
||||||
"general": "GENEL",
|
"general": "GENEL",
|
||||||
"management": "YÖNETİM",
|
"management": "YÖNETİM",
|
||||||
@@ -27,7 +29,7 @@
|
|||||||
"mailboxes": "Mail Kutuları",
|
"mailboxes": "Mail Kutuları",
|
||||||
"aliases": "Alias",
|
"aliases": "Alias",
|
||||||
"users": "Tanımlı Kullanıcı",
|
"users": "Tanımlı Kullanıcı",
|
||||||
"usersSub": "Kullanıcılar .env'den yönetilir",
|
"usersSub": "Kullanıcılar veritabanından yönetilir",
|
||||||
"domainStatus": "Domain Durumu",
|
"domainStatus": "Domain Durumu",
|
||||||
"domain": "Domain",
|
"domain": "Domain",
|
||||||
"quotaUsage": "Kota Kullanımı",
|
"quotaUsage": "Kota Kullanımı",
|
||||||
@@ -40,7 +42,7 @@
|
|||||||
"manageMailboxes": "Mail Hesapları",
|
"manageMailboxes": "Mail Hesapları",
|
||||||
"manageMailboxesDesc": "Yeni hesap oluştur, şifre değiştir, sil",
|
"manageMailboxesDesc": "Yeni hesap oluştur, şifre değiştir, sil",
|
||||||
"manageUsers": "Kullanıcılar",
|
"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": {
|
"domains": {
|
||||||
"title": "Domainler",
|
"title": "Domainler",
|
||||||
@@ -61,16 +63,41 @@
|
|||||||
"tryDiffSearch": "Farklı bir arama yapın"
|
"tryDiffSearch": "Farklı bir arama yapın"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"title": "Sistem Kullanıcıları",
|
"title": "Kullanıcı Yönetimi",
|
||||||
"subtitle": ".env dosyası üzerinden tanımlanmış yetkili kullanıcılar",
|
"subtitle": "Panel erişim yetkilerini ve bildirim ayarlarını yönetin",
|
||||||
"info": "Kullanıcı yönetimi güvenlik nedeniyle sadece çevresel değişkenler (.env) üzerinden yapılmaktadır. Bu ekran salt okunurdur.",
|
"addUser": "Yeni Kullanıcı",
|
||||||
"username": "Kullanıcı Adı",
|
"editUser": "Kullanıcıyı Düzenle",
|
||||||
"name": "Ad Soyad",
|
"username": "Kullanıcı",
|
||||||
"role": "Rol",
|
"role": "Rol",
|
||||||
"domains": "Yetkili Domainler",
|
"domains": "İzin Verilen Domainler",
|
||||||
"superAdmin": "Süper Admin",
|
"superAdmin": "Süper Admin",
|
||||||
"domainAdmin": "Domain 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": {
|
"mailboxes": {
|
||||||
"title": "Mail Hesapları",
|
"title": "Mail Hesapları",
|
||||||
@@ -100,6 +127,8 @@
|
|||||||
"quotaMb": "Kota (MB)",
|
"quotaMb": "Kota (MB)",
|
||||||
"cancel": "İptal",
|
"cancel": "İptal",
|
||||||
"create": "Oluştur",
|
"create": "Oluştur",
|
||||||
|
"notifyUser": "Bildirim Gidecek Kullanıcı (TG)",
|
||||||
|
"noNotify": "Bildirim Gönderme",
|
||||||
"newPasswordFor": "için yeni şifre",
|
"newPasswordFor": "için yeni şifre",
|
||||||
"update": "Güncelle",
|
"update": "Güncelle",
|
||||||
"connectionInfo": "İstemci Bağlantı Bilgileri",
|
"connectionInfo": "İstemci Bağlantı Bilgileri",
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ export default function Sidebar({ dict, lang }: { dict: any; lang: string }) {
|
|||||||
items: [
|
items: [
|
||||||
{ href: `/${lang}/dashboard/domains`, label: dict.domains?.title || "Domainler", icon: GlobeIcon, roles: ["SUPER_ADMIN"] },
|
{ 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/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/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>
|
</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",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
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}`);
|
console.error(`[Mailcow API] Error ${res.status}: ${text}`);
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
@@ -228,3 +229,23 @@ export async function getDKIM(domain: string) {
|
|||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
return res.json();
|
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
|
* lib/users.ts
|
||||||
* Reads user config from environment variables — no database needed.
|
* Manages panel users via PostgreSQL database.
|
||||||
*
|
|
||||||
* .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"
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ model User {
|
|||||||
telegramId String?
|
telegramId String?
|
||||||
mailboxMappings MailboxMapping[]
|
mailboxMappings MailboxMapping[]
|
||||||
notificationConfigs NotificationConfig[]
|
notificationConfigs NotificationConfig[]
|
||||||
|
notificationLogs NotificationLog[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
@@ -41,3 +42,23 @@ model NotificationConfig {
|
|||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
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 { prisma } from "../lib/prisma";
|
||||||
import { getUsers } from "../lib/users";
|
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log("Seeding database...");
|
console.log("Seeding database...");
|
||||||
|
|
||||||
// 1. Migrate Users
|
// Create a default super admin if none exists
|
||||||
const users = await getUsers();
|
const adminEmail = "admin@ayris.tech";
|
||||||
for (const user of users) {
|
|
||||||
console.log(`Migrating user: ${user.email}`);
|
const existingAdmin = await prisma.user.findUnique({
|
||||||
await prisma.user.upsert({
|
where: { email: adminEmail }
|
||||||
where: { email: user.email },
|
});
|
||||||
update: {
|
|
||||||
name: user.name,
|
if (!existingAdmin) {
|
||||||
password: user.password,
|
console.log(`Creating default admin: ${adminEmail}`);
|
||||||
role: user.role,
|
await prisma.user.create({
|
||||||
domains: user.domains,
|
data: {
|
||||||
telegramId: user.telegramId,
|
email: adminEmail,
|
||||||
},
|
name: "System Admin",
|
||||||
create: {
|
password: "admin123", // Should be changed immediately
|
||||||
email: user.email,
|
role: "SUPER_ADMIN",
|
||||||
name: user.name,
|
domains: ["*"],
|
||||||
password: user.password,
|
|
||||||
role: user.role,
|
|
||||||
domains: user.domains,
|
|
||||||
telegramId: user.telegramId,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
} else {
|
||||||
|
console.log("Admin user already exists.");
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Seeding complete.");
|
console.log("Seeding complete.");
|
||||||
|
|||||||
Reference in New Issue
Block a user