first commit
This commit is contained in:
3
app/api/auth/[...nextauth]/route.ts
Normal file
3
app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
18
app/api/domains/[domain]/route.ts
Normal file
18
app/api/domains/[domain]/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { deleteDomain } from "@/lib/mailcow";
|
||||
|
||||
// DELETE /api/domains/[domain] — super admin only
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ domain: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session || session.user.role !== "SUPER_ADMIN") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { domain } = await params;
|
||||
const result = await deleteDomain(decodeURIComponent(domain));
|
||||
return NextResponse.json(result.data, { status: result.ok ? 200 : 502 });
|
||||
}
|
||||
34
app/api/domains/route.ts
Normal file
34
app/api/domains/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { getDomains, createDomain } from "@/lib/mailcow";
|
||||
import { canAccessDomain } from "@/lib/users";
|
||||
|
||||
// GET /api/domains — list domains (filtered by session)
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const allDomains = await getDomains();
|
||||
const userDomains = session.user.domains ?? [];
|
||||
|
||||
// Super admin sees all, domain admin only sees their domains
|
||||
const visible = allDomains.filter((d) => canAccessDomain(userDomains, d.domain_name));
|
||||
|
||||
return NextResponse.json(visible);
|
||||
}
|
||||
|
||||
// POST /api/domains — create domain (super admin only)
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session || session.user.role !== "SUPER_ADMIN") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { domain, description, mailboxes, quota, maxquota } = body;
|
||||
|
||||
if (!domain) return NextResponse.json({ error: "domain gerekli" }, { status: 400 });
|
||||
|
||||
const result = await createDomain({ domain, description, mailboxes, quota, maxquota });
|
||||
return NextResponse.json(result.data, { status: result.ok ? 200 : 502 });
|
||||
}
|
||||
49
app/api/mail/auth/route.ts
Normal file
49
app/api/mail/auth/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { getMailSession, setMailSession, clearMailSession } from "@/lib/mail-session";
|
||||
import { listFolders } from "@/lib/imap";
|
||||
|
||||
// POST /api/mail/auth — login to mailbox (store creds in cookie)
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { email, password } = await req.json();
|
||||
if (!email || !password) {
|
||||
return NextResponse.json({ error: "Email ve şifre gerekli" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Test the credentials by listing folders
|
||||
try {
|
||||
await listFolders({ email, password });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: "IMAP bağlantısı başarısız: " + (err?.message ?? "Bilinmeyen hata") },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
await setMailSession({ email, password });
|
||||
return NextResponse.json({ success: true, email });
|
||||
}
|
||||
|
||||
// GET /api/mail/auth — check if mail session exists
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const mailSession = await getMailSession();
|
||||
if (!mailSession) {
|
||||
return NextResponse.json({ connected: false });
|
||||
}
|
||||
return NextResponse.json({ connected: true, email: mailSession.email });
|
||||
}
|
||||
|
||||
// DELETE /api/mail/auth — logout from mailbox
|
||||
export async function DELETE() {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
await clearMailSession();
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
23
app/api/mail/folders/route.ts
Normal file
23
app/api/mail/folders/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { getMailSession } from "@/lib/mail-session";
|
||||
import { listFolders } from "@/lib/imap";
|
||||
|
||||
// GET /api/mail/folders
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const creds = await getMailSession();
|
||||
if (!creds) return NextResponse.json({ error: "Mail oturumu yok" }, { status: 401 });
|
||||
|
||||
try {
|
||||
const folders = await listFolders(creds);
|
||||
return NextResponse.json(folders);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: "Klasörler alınamadı: " + (err?.message ?? "") },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
44
app/api/mail/messages/[uid]/attachments/route.ts
Normal file
44
app/api/mail/messages/[uid]/attachments/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { getMailSession } from "@/lib/mail-session";
|
||||
import { getAttachment } from "@/lib/imap";
|
||||
|
||||
// GET /api/mail/messages/[uid]/attachments?folder=INBOX&filename=doc.pdf
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ uid: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const creds = await getMailSession();
|
||||
if (!creds) return NextResponse.json({ error: "Mail oturumu yok" }, { status: 401 });
|
||||
|
||||
const { uid } = await params;
|
||||
const folder = req.nextUrl.searchParams.get("folder") ?? "INBOX";
|
||||
const filename = req.nextUrl.searchParams.get("filename");
|
||||
|
||||
if (!filename) {
|
||||
return NextResponse.json({ error: "Dosya adı gerekli" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const att = await getAttachment(creds, folder, parseInt(uid), filename);
|
||||
if (!att) {
|
||||
return NextResponse.json({ error: "Ek bulunamadı" }, { status: 404 });
|
||||
}
|
||||
|
||||
return new NextResponse(att.content, {
|
||||
headers: {
|
||||
"Content-Type": att.contentType,
|
||||
"Content-Disposition": `attachment; filename="${encodeURIComponent(filename)}"`,
|
||||
"Content-Length": String(att.content.length),
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: "Ek indirilemedi: " + (err?.message ?? "") },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
32
app/api/mail/messages/[uid]/route.ts
Normal file
32
app/api/mail/messages/[uid]/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { getMailSession } from "@/lib/mail-session";
|
||||
import { getMessage } from "@/lib/imap";
|
||||
|
||||
// GET /api/mail/messages/[uid]?folder=INBOX
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ uid: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const creds = await getMailSession();
|
||||
if (!creds) return NextResponse.json({ error: "Mail oturumu yok" }, { status: 401 });
|
||||
|
||||
const { uid } = await params;
|
||||
const folder = req.nextUrl.searchParams.get("folder") ?? "INBOX";
|
||||
|
||||
try {
|
||||
const message = await getMessage(creds, folder, parseInt(uid));
|
||||
if (!message) {
|
||||
return NextResponse.json({ error: "Mesaj bulunamadı" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json(message);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: "Mesaj alınamadı: " + (err?.message ?? "") },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
59
app/api/mail/messages/route.ts
Normal file
59
app/api/mail/messages/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { getMailSession } from "@/lib/mail-session";
|
||||
import { listMessages, deleteMessage, moveMessage, toggleFlag } from "@/lib/imap";
|
||||
|
||||
// GET /api/mail/messages?folder=INBOX&page=1
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const creds = await getMailSession();
|
||||
if (!creds) return NextResponse.json({ error: "Mail oturumu yok" }, { status: 401 });
|
||||
|
||||
const folder = req.nextUrl.searchParams.get("folder") ?? "INBOX";
|
||||
const page = parseInt(req.nextUrl.searchParams.get("page") ?? "1");
|
||||
|
||||
try {
|
||||
const result = await listMessages(creds, folder, page, 50);
|
||||
return NextResponse.json(result);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: "Mesajlar alınamadı: " + (err?.message ?? "") },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/mail/messages — actions: delete, move, flag
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const creds = await getMailSession();
|
||||
if (!creds) return NextResponse.json({ error: "Mail oturumu yok" }, { status: 401 });
|
||||
|
||||
const { action, folder, uid, toFolder, flag, add } = await req.json();
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case "delete":
|
||||
await deleteMessage(creds, folder, uid);
|
||||
break;
|
||||
case "move":
|
||||
await moveMessage(creds, folder, uid, toFolder);
|
||||
break;
|
||||
case "flag":
|
||||
await toggleFlag(creds, folder, uid, flag, add);
|
||||
break;
|
||||
default:
|
||||
return NextResponse.json({ error: "Bilinmeyen işlem" }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: "İşlem başarısız: " + (err?.message ?? "") },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
64
app/api/mail/send/route.ts
Normal file
64
app/api/mail/send/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { getMailSession } from "@/lib/mail-session";
|
||||
import { sendMail } from "@/lib/smtp";
|
||||
|
||||
// POST /api/mail/send — supports both JSON and FormData (for attachments)
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const creds = await getMailSession();
|
||||
if (!creds) return NextResponse.json({ error: "Mail oturumu yok" }, { status: 401 });
|
||||
|
||||
const contentType = req.headers.get("content-type") ?? "";
|
||||
|
||||
let to: string, cc: string | undefined, subject: string, html: string, text: string | undefined;
|
||||
let attachments: { filename: string; content: Buffer; contentType?: string }[] = [];
|
||||
|
||||
if (contentType.includes("multipart/form-data")) {
|
||||
// FormData — with attachments
|
||||
const formData = await req.formData();
|
||||
to = formData.get("to") as string;
|
||||
cc = (formData.get("cc") as string) || undefined;
|
||||
subject = formData.get("subject") as string;
|
||||
html = (formData.get("html") as string) || "";
|
||||
text = (formData.get("text") as string) || undefined;
|
||||
|
||||
const files = formData.getAll("attachments") as File[];
|
||||
for (const file of files) {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
attachments.push({
|
||||
filename: file.name,
|
||||
content: buffer,
|
||||
contentType: file.type || undefined,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// JSON — simple message
|
||||
const body = await req.json();
|
||||
to = body.to;
|
||||
cc = body.cc || undefined;
|
||||
subject = body.subject;
|
||||
html = body.html || `<p>${body.text || ""}</p>`;
|
||||
text = body.text || undefined;
|
||||
}
|
||||
|
||||
if (!to || !subject) {
|
||||
return NextResponse.json({ error: "Alıcı ve konu gerekli" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await sendMail(creds, {
|
||||
to,
|
||||
cc,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
return NextResponse.json({ success: true, messageId: result.messageId });
|
||||
}
|
||||
return NextResponse.json({ error: result.error }, { status: 502 });
|
||||
}
|
||||
55
app/api/mailboxes/[email]/route.ts
Normal file
55
app/api/mailboxes/[email]/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { deleteMailbox, editMailbox, getMailboxes } from "@/lib/mailcow";
|
||||
import { canAccessDomain } from "@/lib/users";
|
||||
|
||||
async function checkAccess(session: Awaited<ReturnType<typeof auth>>, email: string) {
|
||||
if (!session) return false;
|
||||
const domain = email.split("@")[1];
|
||||
return canAccessDomain(session.user.domains ?? [], domain);
|
||||
}
|
||||
|
||||
// DELETE /api/mailboxes/[email]
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ email: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
const { email } = await params;
|
||||
const decoded = decodeURIComponent(email);
|
||||
|
||||
if (!(await checkAccess(session, decoded))) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const result = await deleteMailbox([decoded]);
|
||||
return NextResponse.json(result.data, { status: result.ok ? 200 : 502 });
|
||||
}
|
||||
|
||||
// PATCH /api/mailboxes/[email] — password change or toggle active
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ email: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
const { email } = await params;
|
||||
const decoded = decodeURIComponent(email);
|
||||
|
||||
if (!(await checkAccess(session, decoded))) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const attr: Parameters<typeof editMailbox>[1] = {};
|
||||
|
||||
if (body.password) {
|
||||
attr.password = body.password;
|
||||
attr.password2 = body.password;
|
||||
}
|
||||
if (typeof body.active === "number") {
|
||||
attr.active = body.active;
|
||||
}
|
||||
|
||||
const result = await editMailbox([decoded], attr);
|
||||
return NextResponse.json(result.data, { status: result.ok ? 200 : 502 });
|
||||
}
|
||||
40
app/api/mailboxes/route.ts
Normal file
40
app/api/mailboxes/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { getMailboxes, createMailbox } from "@/lib/mailcow";
|
||||
import { canAccessDomain } from "@/lib/users";
|
||||
|
||||
// GET /api/mailboxes?domain=example.com
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const domain = req.nextUrl.searchParams.get("domain");
|
||||
if (!domain) return NextResponse.json({ error: "domain parametresi gerekli" }, { status: 400 });
|
||||
|
||||
if (!canAccessDomain(session.user.domains ?? [], domain)) {
|
||||
return NextResponse.json({ error: "Bu domaine erişim yetkiniz yok" }, { status: 403 });
|
||||
}
|
||||
|
||||
const mailboxes = await getMailboxes(domain);
|
||||
return NextResponse.json(mailboxes);
|
||||
}
|
||||
|
||||
// POST /api/mailboxes
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const body = await req.json();
|
||||
const { local_part, domain, name, password, quota } = body;
|
||||
|
||||
if (!local_part || !domain || !name || !password) {
|
||||
return NextResponse.json({ error: "Eksik alan" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!canAccessDomain(session.user.domains ?? [], domain)) {
|
||||
return NextResponse.json({ error: "Bu domaine erişim yetkiniz yok" }, { status: 403 });
|
||||
}
|
||||
|
||||
const result = await createMailbox({ local_part, domain, name, password, quota });
|
||||
return NextResponse.json(result.data, { status: result.ok ? 200 : 502 });
|
||||
}
|
||||
21
app/api/users/route.ts
Normal file
21
app/api/users/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { getUsers } from "@/lib/users";
|
||||
|
||||
// GET /api/users — super admin only, lists env-defined users (no passwords)
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session || session.user.role !== "SUPER_ADMIN") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const users = getUsers().map(({ id, name, email, role, domains }) => ({
|
||||
id,
|
||||
name,
|
||||
email,
|
||||
role,
|
||||
domains,
|
||||
}));
|
||||
|
||||
return NextResponse.json(users);
|
||||
}
|
||||
232
app/dashboard/domains/page.tsx
Normal file
232
app/dashboard/domains/page.tsx
Normal 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'a domain eklemek için "Domain Ekle" 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'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">Açı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
22
app/dashboard/layout.tsx
Normal 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
200
app/dashboard/mail/page.tsx
Normal 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>;
|
||||
}
|
||||
382
app/dashboard/mailboxes/page.tsx
Normal file
382
app/dashboard/mailboxes/page.tsx
Normal 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
211
app/dashboard/page.tsx
Normal 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>; }
|
||||
142
app/dashboard/users/page.tsx
Normal file
142
app/dashboard/users/page.tsx
Normal 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="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 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>; }
|
||||
1113
app/globals.css
1113
app/globals.css
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "AyrisMail Central",
|
||||
description: "Multi-tenant Mailcow yönetim paneli — AyrisTech",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -23,11 +12,8 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<html lang="tr">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
90
app/login/page.tsx
Normal file
90
app/login/page.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
startTransition(async () => {
|
||||
const res = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (res?.error) {
|
||||
setError("E-posta veya şifre hatalı.");
|
||||
} else {
|
||||
router.push("/dashboard");
|
||||
router.refresh();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-box">
|
||||
<div className="login-header">
|
||||
<div className="login-logo">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#fff" 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>
|
||||
</div>
|
||||
<h1 className="login-title">AyrisMail Central</h1>
|
||||
<p className="login-sub">Mail sunucunuzu kolayca yönetin</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<form onSubmit={handleSubmit} className="form-group">
|
||||
{error && <div className="error-msg">{error}</div>}
|
||||
<div>
|
||||
<label htmlFor="email" className="label">E-posta</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
className="input"
|
||||
placeholder="admin@ayristech.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="label">Şifre</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
className="input"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" disabled={isPending} style={{ width: "100%", justifyContent: "center", padding: "10px" }}>
|
||||
{isPending ? <span className="spinner" /> : null}
|
||||
{isPending ? "Giriş yapılıyor..." : "Giriş Yap"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p style={{ textAlign: "center", marginTop: "20px", fontSize: "12px", color: "var(--text-muted)" }}>
|
||||
AyrisTech © {new Date().getFullYear()} — Tüm hakları saklıdır.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
app/page.tsx
72
app/page.tsx
@@ -1,65 +1,11 @@
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
export default async function HomePage() {
|
||||
const session = await auth();
|
||||
if (session) {
|
||||
redirect("/dashboard");
|
||||
} else {
|
||||
redirect("/login");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user