first commit

This commit is contained in:
AyrisAI
2026-05-14 01:57:52 +03:00
parent 863a32cd35
commit 4a9196f483
47 changed files with 12043 additions and 102 deletions

View File

@@ -0,0 +1,232 @@
"use client";
import { useState, useEffect, useTransition } from "react";
import { formatBytes } from "@/lib/format";
interface Domain {
domain_name: string;
description: string;
active: string;
mboxes_in_domain: number;
mboxes_left: number;
max_num_mboxes_for_domain: number;
aliases_in_domain: number;
quota_used_in_domain: string;
max_quota_for_domain: number;
}
export default function DomainsPage() {
const [domains, setDomains] = useState<Domain[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [isPending, startTransition] = useTransition();
const [search, setSearch] = useState("");
const [form, setForm] = useState({ domain: "", description: "", mailboxes: "10", quota: "10240", maxquota: "10240" });
const [formError, setFormError] = useState("");
const fetchDomains = async () => {
setLoading(true);
const res = await fetch("/api/domains");
if (res.ok) setDomains(await res.json());
setLoading(false);
};
useEffect(() => { fetchDomains(); }, []);
const handleCreate = (e: React.FormEvent) => {
e.preventDefault();
setFormError("");
startTransition(async () => {
const res = await fetch("/api/domains", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
domain: form.domain,
description: form.description,
mailboxes: parseInt(form.mailboxes),
quota: parseInt(form.quota),
maxquota: parseInt(form.maxquota),
}),
});
if (res.ok) {
setShowModal(false);
setForm({ domain: "", description: "", mailboxes: "10", quota: "10240", maxquota: "10240" });
fetchDomains();
} else {
const data = await res.json();
const msg = Array.isArray(data) ? data.map((d: { msg?: string }) => d.msg).join(", ") : (data?.error ?? "Bir hata oluştu");
setFormError(String(msg));
}
});
};
const handleDelete = (domain: string) => {
if (!confirm(`"${domain}" domainini Mailcow'dan silmek istediğinizden emin misiniz?\n\nBu işlem geri alınamaz!`)) return;
startTransition(async () => {
await fetch(`/api/domains/${encodeURIComponent(domain)}`, { method: "DELETE" });
fetchDomains();
});
};
const filtered = domains.filter(
(d) =>
d.domain_name.toLowerCase().includes(search.toLowerCase()) ||
d.description?.toLowerCase().includes(search.toLowerCase())
);
return (
<>
<div className="page-header">
<div>
<h1 className="page-title">Domainler</h1>
<p className="page-subtitle">Mailcow üzerindeki tüm domainleri yönetin</p>
</div>
<button className="btn btn-primary" onClick={() => setShowModal(true)}>
<PlusIcon /> Domain Ekle
</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="Domain veya açıklama ara..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<button className="btn btn-ghost" onClick={fetchDomains}>
<RefreshIcon /> Yenile
</button>
</div>
<div className="table-wrap">
{loading ? (
<div className="empty-state"><span className="spinner" style={{ width: 24, height: 24 }} /></div>
) : filtered.length === 0 ? (
<div className="empty-state">
<div className="empty-icon"><GlobeIcon size={24} /></div>
<div style={{ fontWeight: 600 }}>Domain bulunamadı</div>
<div style={{ fontSize: 12 }}>Mailcow&apos;a domain eklemek için &quot;Domain Ekle&quot; butonuna tıklayın.</div>
</div>
) : (
<table>
<thead>
<tr>
<th>Domain</th>
<th>Mail Kutuları</th>
<th>Alias</th>
<th>Kota</th>
<th>Durum</th>
<th>İşlemler</th>
</tr>
</thead>
<tbody>
{filtered.map((d) => {
const quotaUsed = Number(d.quota_used_in_domain);
const quotaTotal = d.max_quota_for_domain;
const pct = quotaTotal > 0 ? Math.min((quotaUsed / quotaTotal) * 100, 100) : 0;
return (
<tr key={d.domain_name}>
<td>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ width: 28, height: 28, borderRadius: 6, background: "var(--accent-dim)", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--accent-hover)", flexShrink: 0 }}>
<GlobeIcon />
</div>
<div>
<div style={{ fontWeight: 500 }}>{d.domain_name}</div>
{d.description && <div style={{ fontSize: 11, color: "var(--text-secondary)" }}>{d.description}</div>}
</div>
</div>
</td>
<td>
{d.mboxes_in_domain}
<span style={{ color: "var(--text-muted)", fontSize: 12 }}> / {d.max_num_mboxes_for_domain}</span>
</td>
<td>{d.aliases_in_domain}</td>
<td style={{ minWidth: 140 }}>
<div style={{ fontSize: 11, color: "var(--text-secondary)", marginBottom: 4 }}>
{formatBytes(quotaUsed)} / {formatBytes(quotaTotal)}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div className="progress-bar">
<div className={`progress-fill ${pct > 80 ? "danger" : ""}`} style={{ width: `${pct}%` }} />
</div>
<span style={{ fontSize: 11, color: "var(--text-muted)" }}>{Math.round(pct)}%</span>
</div>
</td>
<td>
<span className={`badge ${String(d.active) === "1" ? "badge-green" : "badge-red"}`}>
{String(d.active) === "1" ? "● Aktif" : "● Pasif"}
</span>
</td>
<td>
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(d.domain_name)} disabled={isPending}>
<TrashIcon /> Sil
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
{showModal && (
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && setShowModal(false)}>
<div className="modal">
<div className="modal-header">
<h2 className="modal-title">Mailcow&apos;a Domain Ekle</h2>
<button className="modal-close" onClick={() => setShowModal(false)}><XIcon /></button>
</div>
<form onSubmit={handleCreate}>
<div className="modal-body form-group">
{formError && <div className="error-msg">{formError}</div>}
<div>
<label className="label">Domain Adı</label>
<input type="text" className="input" placeholder="ornek.com" value={form.domain}
onChange={(e) => setForm({ ...form, domain: e.target.value })} required />
</div>
<div>
<label className="label">ıklama (isteğe bağlı)</label>
<input type="text" className="input" placeholder="Bu domain hakkında..." value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })} />
</div>
<div className="form-row">
<div>
<label className="label">Maks. Mailbox</label>
<input type="number" className="input" value={form.mailboxes} min={1}
onChange={(e) => setForm({ ...form, mailboxes: e.target.value })} />
</div>
<div>
<label className="label">Toplam Kota (MB)</label>
<input type="number" className="input" value={form.quota} min={1}
onChange={(e) => setForm({ ...form, quota: e.target.value })} />
</div>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-ghost" onClick={() => setShowModal(false)}>İptal</button>
<button type="submit" className="btn btn-primary" disabled={isPending}>
{isPending ? <span className="spinner" /> : <PlusIcon />} Ekle
</button>
</div>
</form>
</div>
</div>
)}
</>
);
}
function PlusIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>; }
function SearchIcon() { return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>; }
function RefreshIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/></svg>; }
function GlobeIcon({ size = 13 }: { size?: number }) { return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/><path d="M2 12h20"/></svg>; }
function TrashIcon() { return <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>; }
function XIcon() { return <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>; }

22
app/dashboard/layout.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import Providers from "@/components/Providers";
import Sidebar from "@/components/Sidebar";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (!session) redirect("/login");
return (
<Providers>
<div className="app-layout">
<Sidebar />
<div className="main-content">{children}</div>
</div>
</Providers>
);
}

200
app/dashboard/mail/page.tsx Normal file
View File

@@ -0,0 +1,200 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import MailLogin from "@/components/mail/MailLogin";
import FolderList from "@/components/mail/FolderList";
import MessageList from "@/components/mail/MessageList";
import MessageView from "@/components/mail/MessageView";
import ComposeModal from "@/components/mail/ComposeModal";
export interface MailFolder {
name: string;
path: string;
specialUse?: string;
messages: number;
unseen: number;
}
export interface MailEnvelope {
uid: number;
subject: string;
from: { name: string; address: string }[];
to: { name: string; address: string }[];
date: string;
seen: boolean;
flagged: boolean;
hasAttachments: boolean;
}
export interface MailMessage extends MailEnvelope {
cc: { name: string; address: string }[];
html: string;
text: string;
attachments: { filename: string; contentType: string; size: number }[];
}
export default function MailPage() {
const [connected, setConnected] = useState<boolean | null>(null);
const [email, setEmail] = useState("");
const [folders, setFolders] = useState<MailFolder[]>([]);
const [activeFolder, setActiveFolder] = useState("INBOX");
const [messages, setMessages] = useState<MailEnvelope[]>([]);
const [selectedUid, setSelectedUid] = useState<number | null>(null);
const [openMessage, setOpenMessage] = useState<MailMessage | null>(null);
const [loading, setLoading] = useState(false);
const [showCompose, setShowCompose] = useState(false);
const [replyTo, setReplyTo] = useState<MailMessage | null>(null);
// Check connection
useEffect(() => {
fetch("/api/mail/auth")
.then((r) => r.json())
.then((d) => {
setConnected(d.connected);
if (d.email) setEmail(d.email);
});
}, []);
// Load folders
const loadFolders = useCallback(async () => {
const res = await fetch("/api/mail/folders");
if (res.ok) setFolders(await res.json());
}, []);
// Load messages
const loadMessages = useCallback(async (folder: string) => {
setLoading(true);
const res = await fetch(`/api/mail/messages?folder=${encodeURIComponent(folder)}`);
if (res.ok) {
const data = await res.json();
setMessages(data.messages ?? []);
}
setLoading(false);
}, []);
useEffect(() => {
if (connected) {
loadFolders();
loadMessages(activeFolder);
}
}, [connected, activeFolder, loadFolders, loadMessages]);
// Open message
const openMsg = async (uid: number) => {
setSelectedUid(uid);
const res = await fetch(`/api/mail/messages/${uid}?folder=${encodeURIComponent(activeFolder)}`);
if (res.ok) {
const msg = await res.json();
setOpenMessage(msg);
// Mark as read in list
setMessages((prev) => prev.map((m) => m.uid === uid ? { ...m, seen: true } : m));
}
};
// Delete
const handleDelete = async (uid: number) => {
await fetch("/api/mail/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "delete", folder: activeFolder, uid }),
});
setMessages((prev) => prev.filter((m) => m.uid !== uid));
if (selectedUid === uid) { setSelectedUid(null); setOpenMessage(null); }
};
// Reply
const handleReply = (msg: MailMessage) => {
setReplyTo(msg);
setShowCompose(true);
};
// Disconnect
const handleDisconnect = async () => {
await fetch("/api/mail/auth", { method: "DELETE" });
setConnected(false);
setEmail("");
setFolders([]);
setMessages([]);
setOpenMessage(null);
};
if (connected === null) {
return <div className="empty-state"><span className="spinner" style={{ width: 24, height: 24 }} /></div>;
}
if (!connected) {
return <MailLogin onSuccess={(e) => { setConnected(true); setEmail(e); }} />;
}
return (
<div className="mail-layout">
<div className="mail-sidebar">
<button className="btn btn-primary" style={{ width: "100%" }} onClick={() => { setReplyTo(null); setShowCompose(true); }}>
<ComposeIcon /> Yeni Mail
</button>
<FolderList
folders={folders}
active={activeFolder}
onSelect={(f) => { setActiveFolder(f); setSelectedUid(null); setOpenMessage(null); }}
/>
<div className="mail-account">
<div className="mail-account-email">{email}</div>
<button className="btn btn-ghost btn-sm" onClick={handleDisconnect} style={{ fontSize: 11 }}>
Çıkış
</button>
</div>
</div>
<div className="mail-list">
<div className="mail-list-header">
<h2>
{folders.find((f) => f.path === activeFolder)?.name ?? activeFolder}
</h2>
<button className="btn btn-ghost btn-sm" onClick={() => loadMessages(activeFolder)}></button>
</div>
<MessageList
messages={messages}
loading={loading}
selectedUid={selectedUid}
onSelect={openMsg}
onDelete={handleDelete}
/>
</div>
<div className="mail-detail">
{openMessage ? (
<MessageView
message={openMessage}
onReply={() => handleReply(openMessage)}
onDelete={() => handleDelete(openMessage.uid)}
folder={activeFolder}
/>
) : (
<div className="mail-empty">
<div className="mail-empty-icon">
<MailBigIcon />
</div>
<div style={{ fontWeight: 600, fontSize: 14, color: "var(--text-secondary)" }}>Bir mail seçin</div>
<div style={{ fontSize: 12 }}>Okumak için soldaki listeden bir mail seçin</div>
</div>
)}
</div>
{showCompose && (
<ComposeModal
replyTo={replyTo}
onClose={() => { setShowCompose(false); setReplyTo(null); }}
onSent={() => { setShowCompose(false); setReplyTo(null); loadMessages(activeFolder); }}
/>
)}
</div>
);
}
function MailBigIcon() {
return <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.5 }}><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>;
}
function ComposeIcon() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>;
}

View File

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

211
app/dashboard/page.tsx Normal file
View File

@@ -0,0 +1,211 @@
import { auth } from "@/auth";
import { getDomains } from "@/lib/mailcow";
import { canAccessDomain } from "@/lib/users";
import { formatBytes } from "@/lib/format";
export default async function DashboardPage() {
const session = await auth();
const role = session?.user?.role;
const userDomains = session?.user?.domains ?? [];
const allDomains = await getDomains();
const visibleDomains = allDomains.filter((d) => canAccessDomain(userDomains, d.domain_name));
const totalMailboxes = visibleDomains.reduce((sum, d) => sum + d.mboxes_in_domain, 0);
const totalAliases = visibleDomains.reduce((sum, d) => sum + d.aliases_in_domain, 0);
return (
<>
<div className="page-header">
<div>
<h1 className="page-title">Dashboard</h1>
<p className="page-subtitle">Hoş geldiniz, {session?.user?.name} 👋</p>
</div>
</div>
<div className="page-body">
<div className="stats-grid">
<StatCard
label="Toplam Domain"
value={visibleDomains.length}
color="var(--accent)"
icon={<GlobeIcon />}
/>
<StatCard
label="Mail Kutuları"
value={totalMailboxes}
color="var(--success)"
icon={<MailIcon />}
/>
<StatCard
label="Alias"
value={totalAliases}
color="var(--warning)"
icon={<AtIcon />}
/>
{role === "SUPER_ADMIN" && (
<StatCard
label="Tanımlı Kullanıcı"
value={"—"}
sub="Kullanıcılar .env'den yönetilir"
color="var(--text-muted)"
icon={<UsersIcon />}
/>
)}
</div>
{/* Domain durum tablosu */}
{visibleDomains.length > 0 && (
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div style={{ padding: "16px 20px", borderBottom: "1px solid var(--border)" }}>
<h2 style={{ fontSize: 14, fontWeight: 700, color: "var(--text-primary)" }}>
Domain Durumu
</h2>
</div>
<div className="table-wrap" style={{ border: "none", borderRadius: 0 }}>
<table>
<thead>
<tr>
<th>Domain</th>
<th>Mail Kutuları</th>
<th>Kota Kullanımı</th>
<th>Durum</th>
</tr>
</thead>
<tbody>
{visibleDomains.map((d) => {
const quotaUsed = Number(d.quota_used_in_domain);
const quotaTotal = d.max_quota_for_domain;
const pct = quotaTotal > 0 ? Math.min((quotaUsed / quotaTotal) * 100, 100) : 0;
return (
<tr key={d.domain_name}>
<td>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ width: 28, height: 28, borderRadius: 6, background: "var(--accent-dim)", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--accent-hover)", flexShrink: 0 }}>
<GlobeIcon />
</div>
<span style={{ fontWeight: 500 }}>{d.domain_name}</span>
</div>
</td>
<td>
<span>{d.mboxes_in_domain}</span>
<span style={{ color: "var(--text-muted)", fontSize: 12 }}> / {d.max_num_mboxes_for_domain}</span>
</td>
<td style={{ minWidth: 160 }}>
<div style={{ fontSize: 11, color: "var(--text-secondary)", marginBottom: 4 }}>
{formatBytes(quotaUsed)} / {formatBytes(quotaTotal)}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div className="progress-bar">
<div className={`progress-fill ${pct > 80 ? "danger" : ""}`} style={{ width: `${pct}%` }} />
</div>
<span style={{ fontSize: 11, color: "var(--text-muted)", flexShrink: 0 }}>{Math.round(pct)}%</span>
</div>
</td>
<td>
<span className={`badge ${String(d.active) === "1" ? "badge-green" : "badge-red"}`}>
{String(d.active) === "1" ? "● Aktif" : "● Pasif"}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* Quick actions */}
<div className="card">
<h2 style={{ fontSize: 14, fontWeight: 700, marginBottom: 16, color: "var(--text-primary)" }}>
Hızlı İşlemler
</h2>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{role === "SUPER_ADMIN" && (
<QuickItem
href="/dashboard/domains"
icon={<GlobeIcon />}
title="Domain Yönetimi"
desc="Domain ekle, sil, yönet"
color="var(--accent)"
/>
)}
<QuickItem
href="/dashboard/mailboxes"
icon={<MailIcon />}
title="Mail Hesapları"
desc="Yeni hesap oluştur, şifre değiştir, sil"
color="var(--success)"
/>
{role === "SUPER_ADMIN" && (
<QuickItem
href="/dashboard/users"
icon={<UsersIcon />}
title="Kullanıcılar"
desc=".env'den tanımlı panel kullanıcılarını görüntüle"
color="var(--warning)"
/>
)}
</div>
</div>
</div>
</>
);
}
function StatCard({ label, value, sub, color, icon }: {
label: string;
value: number | string;
sub?: string;
color: string;
icon: React.ReactNode;
}) {
return (
<div className="stat-card">
<div className="stat-icon" style={{ background: `${color}20`, color }}>
{icon}
</div>
<div className="stat-label">{label}</div>
<div className="stat-value">{value}</div>
{sub && <div style={{ fontSize: 11, color: "var(--text-muted)" }}>{sub}</div>}
</div>
);
}
function QuickItem({ href, icon, title, desc, color }: {
href: string;
icon: React.ReactNode;
title: string;
desc: string;
color: string;
}) {
return (
<a
href={href}
style={{
display: "flex", alignItems: "center", gap: 14, padding: 14,
borderRadius: "var(--radius)", border: "1px solid var(--border)",
background: "var(--bg)", textDecoration: "none",
transition: "all 0.15s ease",
}}
>
<div style={{ width: 36, height: 36, borderRadius: 8, background: `${color}20`, color, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
{icon}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--text-primary)" }}>{title}</div>
<div style={{ fontSize: 12, color: "var(--text-secondary)", marginTop: 2 }}>{desc}</div>
</div>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m9 18 6-6-6-6" />
</svg>
</a>
);
}
// Icons
function GlobeIcon() { return <svg width="15" height="15" 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 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/><path d="M2 12h20"/></svg>; }
function MailIcon() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>; }
function AtIcon() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94"/></svg>; }
function UsersIcon() { return <svg width="15" height="15" 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>; }

View File

@@ -0,0 +1,142 @@
"use client";
import { useState, useEffect } from "react";
interface User {
id: string;
name: string;
email: string;
role: string;
domains: string[];
}
export default function UsersPage() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
useEffect(() => {
fetch("/api/users")
.then((r) => r.json())
.then((data) => {
setUsers(Array.isArray(data) ? data : []);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const filtered = users.filter(
(u) =>
u.name.toLowerCase().includes(search.toLowerCase()) ||
u.email.toLowerCase().includes(search.toLowerCase())
);
return (
<>
<div className="page-header">
<div>
<h1 className="page-title">Kullanıcılar</h1>
<p className="page-subtitle">Panel kullanıcıları .env dosyasından yönetilir</p>
</div>
</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 }}>Kullanıcı yönetimi hakkında</div>
<div style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.7 }}>
Kullanıcılar <code style={{ background: "var(--bg)", padding: "1px 6px", borderRadius: 4, fontSize: 12 }}>.env</code> dosyasındaki{" "}
<code style={{ background: "var(--bg)", padding: "1px 6px", borderRadius: 4, fontSize: 12 }}>USER_0_*</code>,{" "}
<code style={{ background: "var(--bg)", padding: "1px 6px", borderRadius: 4, fontSize: 12 }}>USER_1_*</code> değişkenleriyle tanımlanır.
Yeni kullanıcı eklemek için .env dosyasını düzenleyip uygulamayı yeniden başlatın.
</div>
<div style={{ marginTop: 12, padding: "10px 14px", background: "var(--bg)", borderRadius: "var(--radius)", fontSize: 12, fontFamily: "monospace", color: "var(--text-secondary)", lineHeight: 2 }}>
USER_2_NAME=&quot;Ahmet Yılmaz&quot;<br />
USER_2_EMAIL=&quot;ahmet@ayristech.com&quot;<br />
USER_2_PASSWORD=&quot;güçlü-şifre&quot;<br />
USER_2_ROLE=&quot;DOMAIN_ADMIN&quot;<br />
USER_2_DOMAINS=&quot;yenidomain.com&quot;
</div>
</div>
</div>
<div className="search-bar">
<div className="search-input-wrap">
<span className="search-icon"><SearchIcon /></span>
<input
type="text"
className="input search-input"
placeholder="İsim veya e-posta ara..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className="table-wrap">
{loading ? (
<div className="empty-state">
<span className="spinner" style={{ width: 24, height: 24 }} />
</div>
) : filtered.length === 0 ? (
<div className="empty-state">
<div className="empty-icon"><UsersIcon /></div>
<div style={{ fontWeight: 600 }}>Kullanıcı bulunamadı</div>
</div>
) : (
<table>
<thead>
<tr>
<th>Kullanıcı</th>
<th>Rol</th>
<th>İzin Verilen Domainler</th>
</tr>
</thead>
<tbody>
{filtered.map((u) => (
<tr key={u.id}>
<td>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div className="user-avatar" style={{ width: 32, height: 32, fontSize: 13 }}>
{u.name[0]?.toUpperCase()}
</div>
<div>
<div style={{ fontWeight: 500 }}>{u.name}</div>
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>{u.email}</div>
</div>
</div>
</td>
<td>
<span className={`badge ${u.role === "SUPER_ADMIN" ? "badge-blue" : "badge-green"}`}>
{u.role === "SUPER_ADMIN" ? "★ Süper Admin" : "Domain Admin"}
</span>
</td>
<td>
{u.domains.includes("*") ? (
<span className="badge badge-blue">Tüm domainler</span>
) : (
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
{u.domains.map((d) => (
<span key={d} className="badge badge-green">{d}</span>
))}
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</>
);
}
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 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>; }