diff --git a/.gitignore b/.gitignore
index 5ef6a52..45254b6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,3 +39,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
+
+/app/generated/prisma
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..9a39dd9
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,54 @@
+# 1. Base image
+FROM node:20-alpine AS base
+
+# 2. Dependencies
+FROM base AS deps
+RUN apk add --no-cache libc6-compat
+WORKDIR /app
+
+# Install dependencies
+COPY package.json package-lock.json* ./
+RUN npm ci --legacy-peer-deps
+
+
+# 3. Builder
+FROM base AS builder
+WORKDIR /app
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+
+
+
+# Environment variables must be present at build time for Next.js
+ENV NEXT_TELEMETRY_DISABLED=1
+
+RUN npm run build
+
+# 4. Runner
+FROM base AS runner
+WORKDIR /app
+
+ENV NODE_ENV=production
+ENV NEXT_TELEMETRY_DISABLED=1
+
+RUN addgroup --system --gid 1001 nodejs
+RUN adduser --system --uid 1001 nextjs
+
+COPY --from=builder /app/public ./public
+
+# Set the correct permission for prerender cache
+RUN mkdir .next
+RUN chown nextjs:nodejs .next
+
+# Automatically leverage output traces to reduce image size
+COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
+COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+
+USER nextjs
+
+EXPOSE 3000
+
+ENV PORT=3000
+ENV HOSTNAME="0.0.0.0"
+
+CMD ["node", "server.js"]
diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts
new file mode 100644
index 0000000..86c9f3d
--- /dev/null
+++ b/app/api/auth/[...nextauth]/route.ts
@@ -0,0 +1,3 @@
+import { handlers } from "@/auth";
+
+export const { GET, POST } = handlers;
diff --git a/app/api/domains/[domain]/route.ts b/app/api/domains/[domain]/route.ts
new file mode 100644
index 0000000..4ec9df6
--- /dev/null
+++ b/app/api/domains/[domain]/route.ts
@@ -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 });
+}
diff --git a/app/api/domains/route.ts b/app/api/domains/route.ts
new file mode 100644
index 0000000..557fed6
--- /dev/null
+++ b/app/api/domains/route.ts
@@ -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 });
+}
diff --git a/app/api/mail/auth/route.ts b/app/api/mail/auth/route.ts
new file mode 100644
index 0000000..10e24f0
--- /dev/null
+++ b/app/api/mail/auth/route.ts
@@ -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 });
+}
diff --git a/app/api/mail/folders/route.ts b/app/api/mail/folders/route.ts
new file mode 100644
index 0000000..be4874c
--- /dev/null
+++ b/app/api/mail/folders/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/app/api/mail/messages/[uid]/attachments/route.ts b/app/api/mail/messages/[uid]/attachments/route.ts
new file mode 100644
index 0000000..479fa30
--- /dev/null
+++ b/app/api/mail/messages/[uid]/attachments/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/app/api/mail/messages/[uid]/route.ts b/app/api/mail/messages/[uid]/route.ts
new file mode 100644
index 0000000..a15908f
--- /dev/null
+++ b/app/api/mail/messages/[uid]/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/app/api/mail/messages/route.ts b/app/api/mail/messages/route.ts
new file mode 100644
index 0000000..a0fc29d
--- /dev/null
+++ b/app/api/mail/messages/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/app/api/mail/send/route.ts b/app/api/mail/send/route.ts
new file mode 100644
index 0000000..1d55e1d
--- /dev/null
+++ b/app/api/mail/send/route.ts
@@ -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 || `
${body.text || ""}
`;
+ 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 });
+}
diff --git a/app/api/mailboxes/[email]/route.ts b/app/api/mailboxes/[email]/route.ts
new file mode 100644
index 0000000..2fe3c9b
--- /dev/null
+++ b/app/api/mailboxes/[email]/route.ts
@@ -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>, 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[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 });
+}
diff --git a/app/api/mailboxes/route.ts b/app/api/mailboxes/route.ts
new file mode 100644
index 0000000..c4e068a
--- /dev/null
+++ b/app/api/mailboxes/route.ts
@@ -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 });
+}
diff --git a/app/api/users/route.ts b/app/api/users/route.ts
new file mode 100644
index 0000000..345d4d7
--- /dev/null
+++ b/app/api/users/route.ts
@@ -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);
+}
diff --git a/app/dashboard/domains/page.tsx b/app/dashboard/domains/page.tsx
new file mode 100644
index 0000000..b22ba4e
--- /dev/null
+++ b/app/dashboard/domains/page.tsx
@@ -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([]);
+ 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 (
+ <>
+
+
+
Domainler
+
Mailcow üzerindeki tüm domainleri yönetin
+
+
+
+
+
+
+
+
+ setSearch(e.target.value)}
+ />
+
+
+
+
+
+ {loading ? (
+
+ ) : filtered.length === 0 ? (
+
+
+
Domain bulunamadı
+
Mailcow'a domain eklemek için "Domain Ekle" butonuna tıklayın.
+
+ ) : (
+
+
+
+ | Domain |
+ Mail Kutuları |
+ Alias |
+ Kota |
+ Durum |
+ İşlemler |
+
+
+
+ {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 (
+
+
+
+
+
+
+
+ {d.domain_name}
+ {d.description && {d.description} }
+
+
+ |
+
+ {d.mboxes_in_domain}
+ / {d.max_num_mboxes_for_domain}
+ |
+ {d.aliases_in_domain} |
+
+
+ {formatBytes(quotaUsed)} / {formatBytes(quotaTotal)}
+
+
+
+ 80 ? "danger" : ""}`} style={{ width: `${pct}%` }} />
+
+ {Math.round(pct)}%
+
+ |
+
+
+ {String(d.active) === "1" ? "● Aktif" : "● Pasif"}
+
+ |
+
+
+ |
+
+ );
+ })}
+
+
+ )}
+
+
+
+ {showModal && (
+ e.target === e.currentTarget && setShowModal(false)}>
+
+
+
Mailcow'a Domain Ekle
+
+
+
+
+
+ )}
+ >
+ );
+}
+
+function PlusIcon() { return ; }
+function SearchIcon() { return ; }
+function RefreshIcon() { return ; }
+function GlobeIcon({ size = 13 }: { size?: number }) { return ; }
+function TrashIcon() { return ; }
+function XIcon() { return ; }
diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx
new file mode 100644
index 0000000..7382cf8
--- /dev/null
+++ b/app/dashboard/layout.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/app/dashboard/mail/page.tsx b/app/dashboard/mail/page.tsx
new file mode 100644
index 0000000..31274d5
--- /dev/null
+++ b/app/dashboard/mail/page.tsx
@@ -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(null);
+ const [email, setEmail] = useState("");
+ const [folders, setFolders] = useState([]);
+ const [activeFolder, setActiveFolder] = useState("INBOX");
+ const [messages, setMessages] = useState([]);
+ const [selectedUid, setSelectedUid] = useState(null);
+ const [openMessage, setOpenMessage] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [showCompose, setShowCompose] = useState(false);
+ const [replyTo, setReplyTo] = useState(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
;
+ }
+
+ if (!connected) {
+ return { setConnected(true); setEmail(e); }} />;
+ }
+
+ return (
+
+
+
+
{ setActiveFolder(f); setSelectedUid(null); setOpenMessage(null); }}
+ />
+
+
+
+
+
+
+ {folders.find((f) => f.path === activeFolder)?.name ?? activeFolder}
+
+
+
+
+
+
+
+ {openMessage ? (
+
handleReply(openMessage)}
+ onDelete={() => handleDelete(openMessage.uid)}
+ folder={activeFolder}
+ />
+ ) : (
+
+
+
+
+
Bir mail seçin
+
Okumak için soldaki listeden bir mail seçin
+
+ )}
+
+
+ {showCompose && (
+
{ setShowCompose(false); setReplyTo(null); }}
+ onSent={() => { setShowCompose(false); setReplyTo(null); loadMessages(activeFolder); }}
+ />
+ )}
+
+ );
+}
+
+function MailBigIcon() {
+ return ;
+}
+
+function ComposeIcon() {
+ return ;
+}
diff --git a/app/dashboard/mailboxes/page.tsx b/app/dashboard/mailboxes/page.tsx
new file mode 100644
index 0000000..a513808
--- /dev/null
+++ b/app/dashboard/mailboxes/page.tsx
@@ -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([]);
+ const [selectedDomain, setSelectedDomain] = useState("");
+ const [mailboxes, setMailboxes] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [showCreateModal, setShowCreateModal] = useState(false);
+ const [showPasswordModal, setShowPasswordModal] = useState(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 (
+ <>
+
+
+
Mail Hesapları
+
+ {selectedDomain
+ ? `${selectedDomain} — ${mailboxes.length} hesap`
+ : "Domain seçin"}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setSearch(e.target.value)}
+ />
+
+
+
+
+ {loading ? (
+
+
+
+ ) : filtered.length === 0 ? (
+
+
+
+ {selectedDomain ? "Bu domainde mail hesabı yok" : "Domain seçin"}
+
+
+ {selectedDomain ? '"Hesap Ekle" butonuna tıklayın' : "Sol üstteki listeden domain seçin"}
+
+
+ ) : (
+
+
+
+ | E-posta |
+ Ad Soyad |
+ Kota |
+ Durum |
+ İşlemler |
+
+
+
+ {filtered.map((m) => {
+ const usedPct = m.quota > 0 ? Math.min((m.quota_used / m.quota) * 100, 100) : 0;
+ return (
+
+
+
+
+ {m.local_part[0]?.toUpperCase()}
+
+ {m.username}
+
+ |
+ {m.name} |
+
+
+ {formatBytes(m.quota_used)} / {formatBytes(m.quota)}
+
+
+
+ 80 ? "danger" : ""}`} style={{ width: `${usedPct}%` }} />
+
+ {Math.round(usedPct)}%
+
+ |
+
+
+ {String(m.active) === "1" ? "● Aktif" : "● Pasif"}
+
+ |
+
+
+
+
+
+
+ |
+
+ );
+ })}
+
+
+ )}
+
+
+
+ {/* Create Modal */}
+ {showCreateModal && (
+ e.target === e.currentTarget && setShowCreateModal(false)}>
+
+
+
Yeni Mail Hesabı
+
+
+
+
+
+ )}
+
+ {/* Password Modal */}
+ {showPasswordModal && (
+ e.target === e.currentTarget && setShowPasswordModal(null)}>
+
+
+
Şifre Değiştir
+
+
+
+
+
+ )}
+ >
+ );
+}
+
+// Icons
+function PlusIcon() { return ; }
+function SearchIcon() { return ; }
+function RefreshIcon() { return ; }
+function MailIcon({ size = 13 }: { size?: number }) { return ; }
+function TrashIcon() { return ; }
+function KeyIcon() { return ; }
+function PauseIcon() { return ; }
+function PlayIcon() { return ; }
+function XIcon() { return ; }
diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx
new file mode 100644
index 0000000..1357455
--- /dev/null
+++ b/app/dashboard/page.tsx
@@ -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 (
+ <>
+
+
+
Dashboard
+
Hoş geldiniz, {session?.user?.name} 👋
+
+
+
+
+
+ }
+ />
+ }
+ />
+ }
+ />
+ {role === "SUPER_ADMIN" && (
+ }
+ />
+ )}
+
+
+ {/* Domain durum tablosu */}
+ {visibleDomains.length > 0 && (
+
+
+
+ Domain Durumu
+
+
+
+
+
+
+ | Domain |
+ Mail Kutuları |
+ Kota Kullanımı |
+ Durum |
+
+
+
+ {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 (
+
+
+
+
+
+
+ {d.domain_name}
+
+ |
+
+ {d.mboxes_in_domain}
+ / {d.max_num_mboxes_for_domain}
+ |
+
+
+ {formatBytes(quotaUsed)} / {formatBytes(quotaTotal)}
+
+
+
+ 80 ? "danger" : ""}`} style={{ width: `${pct}%` }} />
+
+ {Math.round(pct)}%
+
+ |
+
+
+ {String(d.active) === "1" ? "● Aktif" : "● Pasif"}
+
+ |
+
+ );
+ })}
+
+
+
+
+ )}
+
+ {/* Quick actions */}
+
+
+ Hızlı İşlemler
+
+
+ {role === "SUPER_ADMIN" && (
+ }
+ title="Domain Yönetimi"
+ desc="Domain ekle, sil, yönet"
+ color="var(--accent)"
+ />
+ )}
+ }
+ title="Mail Hesapları"
+ desc="Yeni hesap oluştur, şifre değiştir, sil"
+ color="var(--success)"
+ />
+ {role === "SUPER_ADMIN" && (
+ }
+ title="Kullanıcılar"
+ desc=".env'den tanımlı panel kullanıcılarını görüntüle"
+ color="var(--warning)"
+ />
+ )}
+
+
+
+ >
+ );
+}
+
+function StatCard({ label, value, sub, color, icon }: {
+ label: string;
+ value: number | string;
+ sub?: string;
+ color: string;
+ icon: React.ReactNode;
+}) {
+ return (
+
+
+ {icon}
+
+
{label}
+
{value}
+ {sub &&
{sub}
}
+
+ );
+}
+
+function QuickItem({ href, icon, title, desc, color }: {
+ href: string;
+ icon: React.ReactNode;
+ title: string;
+ desc: string;
+ color: string;
+}) {
+ return (
+
+
+ {icon}
+
+
+
+
+ );
+}
+
+// Icons
+function GlobeIcon() { return ; }
+function MailIcon() { return ; }
+function AtIcon() { return ; }
+function UsersIcon() { return ; }
diff --git a/app/dashboard/users/page.tsx b/app/dashboard/users/page.tsx
new file mode 100644
index 0000000..de6dda2
--- /dev/null
+++ b/app/dashboard/users/page.tsx
@@ -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([]);
+ 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 (
+ <>
+
+
+
Kullanıcılar
+
Panel kullanıcıları .env dosyasından yönetilir
+
+
+
+
+ {/* Info card */}
+
+
+
+
+
+
Kullanıcı yönetimi hakkında
+
+ Kullanıcılar .env dosyasındaki{" "}
+ USER_0_*,{" "}
+ USER_1_*… değişkenleriyle tanımlanır.
+ Yeni kullanıcı eklemek için .env dosyasını düzenleyip uygulamayı yeniden başlatın.
+
+
+ USER_2_NAME="Ahmet Yılmaz"
+ USER_2_EMAIL="ahmet@ayristech.com"
+ USER_2_PASSWORD="güçlü-şifre"
+ USER_2_ROLE="DOMAIN_ADMIN"
+ USER_2_DOMAINS="yenidomain.com"
+
+
+
+
+
+
+
+ setSearch(e.target.value)}
+ />
+
+
+
+
+ {loading ? (
+
+
+
+ ) : filtered.length === 0 ? (
+
+
+
Kullanıcı bulunamadı
+
+ ) : (
+
+
+
+ | Kullanıcı |
+ Rol |
+ İzin Verilen Domainler |
+
+
+
+ {filtered.map((u) => (
+
+
+
+
+ {u.name[0]?.toUpperCase()}
+
+
+
+ |
+
+
+ {u.role === "SUPER_ADMIN" ? "★ Süper Admin" : "Domain Admin"}
+
+ |
+
+ {u.domains.includes("*") ? (
+ Tüm domainler
+ ) : (
+
+ {u.domains.map((d) => (
+ {d}
+ ))}
+
+ )}
+ |
+
+ ))}
+
+
+ )}
+
+
+ >
+ );
+}
+
+function SearchIcon() { return ; }
+function UsersIcon() { return ; }
+function InfoIcon() { return ; }
diff --git a/app/globals.css b/app/globals.css
index a2dc41e..2a7d952 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1,26 +1,1107 @@
-@import "tailwindcss";
+@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap");
+
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+html { -webkit-text-size-adjust: 100%; tab-size: 4; }
+body { line-height: 1.5; -webkit-font-smoothing: antialiased; }
+img, svg, video { display: block; max-width: 100%; }
+input, button, textarea, select { font: inherit; }
:root {
- --background: #ffffff;
- --foreground: #171717;
+ --bg: #0d1117;
+ --bg-card: #161b22;
+ --bg-hover: #1c2128;
+ --border: #30363d;
+ --border-focus: #388bfd;
+ --text-primary: #e6edf3;
+ --text-secondary: #8b949e;
+ --text-muted: #484f58;
+ --accent: #388bfd;
+ --accent-hover: #58a6ff;
+ --accent-dim: rgba(56, 139, 253, 0.12);
+ --success: #3fb950;
+ --success-dim: rgba(63, 185, 80, 0.12);
+ --warning: #d29922;
+ --warning-dim: rgba(210, 153, 34, 0.12);
+ --danger: #f85149;
+ --danger-hover: #ff7b72;
+ --danger-dim: rgba(248, 81, 73, 0.12);
+ --radius: 8px;
+ --radius-lg: 12px;
+ --shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.24);
+ --shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.5);
}
-@theme inline {
- --color-background: var(--background);
- --color-foreground: var(--foreground);
- --font-sans: var(--font-geist-sans);
- --font-mono: var(--font-geist-mono);
+
+
+
+html,
+body {
+ height: 100%;
+ background: var(--bg);
+ color: var(--text-primary);
+ font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ font-size: 14px;
+ line-height: 1.5;
+ -webkit-font-smoothing: antialiased;
}
-@media (prefers-color-scheme: dark) {
- :root {
- --background: #0a0a0a;
- --foreground: #ededed;
+a {
+ color: var(--accent-hover);
+ text-decoration: none;
+}
+
+/* Scrollbar */
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 3px;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: var(--text-muted);
+}
+
+/* ── Card ── */
+.card {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 24px;
+ box-shadow: var(--shadow);
+}
+
+/* ── Button ── */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 7px 14px;
+ border-radius: var(--radius);
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ border: 1px solid transparent;
+ transition: all 0.15s ease;
+ white-space: nowrap;
+ font-family: inherit;
+}
+
+.btn-primary {
+ background: var(--accent);
+ color: #fff;
+ border-color: var(--accent);
+}
+.btn-primary:hover {
+ background: var(--accent-hover);
+ border-color: var(--accent-hover);
+}
+
+.btn-ghost {
+ background: transparent;
+ color: var(--text-secondary);
+ border-color: var(--border);
+}
+.btn-ghost:hover {
+ background: var(--bg-hover);
+ color: var(--text-primary);
+}
+
+.btn-danger {
+ background: var(--danger-dim);
+ color: var(--danger);
+ border-color: var(--danger);
+}
+.btn-danger:hover {
+ background: var(--danger);
+ color: #fff;
+}
+
+.btn-success {
+ background: var(--success-dim);
+ color: var(--success);
+ border-color: var(--success);
+}
+.btn-success:hover {
+ background: var(--success);
+ color: #fff;
+}
+
+.btn-sm {
+ padding: 4px 10px;
+ font-size: 12px;
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* ── Input ── */
+.input {
+ width: 100%;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 8px 12px;
+ color: var(--text-primary);
+ font-size: 13px;
+ font-family: inherit;
+ outline: none;
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
+}
+
+.input:focus {
+ border-color: var(--border-focus);
+ box-shadow: 0 0 0 3px var(--accent-dim);
+}
+
+.input::placeholder {
+ color: var(--text-muted);
+}
+
+select.input {
+ cursor: pointer;
+}
+
+/* ── Label ── */
+.label {
+ display: block;
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text-secondary);
+ margin-bottom: 6px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+/* ── Form group ── */
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.form-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+}
+
+@media (max-width: 600px) {
+ .form-row {
+ grid-template-columns: 1fr;
}
}
-body {
- background: var(--background);
- color: var(--foreground);
- font-family: Arial, Helvetica, sans-serif;
+/* ── Table ── */
+.table-wrap {
+ overflow-x: auto;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+thead {
+ background: var(--bg-hover);
+}
+
+th {
+ padding: 10px 16px;
+ text-align: left;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ border-bottom: 1px solid var(--border);
+ white-space: nowrap;
+}
+
+td {
+ padding: 12px 16px;
+ font-size: 13px;
+ color: var(--text-primary);
+ border-bottom: 1px solid var(--border);
+}
+
+tr:last-child td {
+ border-bottom: none;
+}
+
+tr:hover td {
+ background: var(--bg-hover);
+}
+
+/* ── Badge ── */
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 8px;
+ border-radius: 20px;
+ font-size: 11px;
+ font-weight: 600;
+}
+
+.badge-green {
+ background: var(--success-dim);
+ color: var(--success);
+}
+
+.badge-red {
+ background: var(--danger-dim);
+ color: var(--danger);
+}
+
+.badge-yellow {
+ background: var(--warning-dim);
+ color: var(--warning);
+}
+
+.badge-blue {
+ background: var(--accent-dim);
+ color: var(--accent-hover);
+}
+
+/* ── Stat Card ── */
+.stat-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ transition: border-color 0.15s ease;
+}
+
+.stat-card:hover {
+ border-color: var(--accent);
+}
+
+.stat-label {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.stat-value {
+ font-size: 28px;
+ font-weight: 700;
+ color: var(--text-primary);
+ line-height: 1;
+}
+
+.stat-icon {
+ width: 36px;
+ height: 36px;
+ border-radius: var(--radius);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-bottom: 4px;
+}
+
+/* ── Sidebar ── */
+.sidebar {
+ width: 240px;
+ min-height: 100vh;
+ background: var(--bg-card);
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ flex-shrink: 0;
+}
+
+.sidebar-logo {
+ padding: 20px 24px;
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.sidebar-logo-icon {
+ width: 32px;
+ height: 32px;
+ background: linear-gradient(135deg, var(--accent), #7c3aed);
+ border-radius: 8px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.sidebar-logo-text {
+ font-size: 15px;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.sidebar-logo-sub {
+ font-size: 11px;
+ color: var(--text-secondary);
+ line-height: 1.2;
+}
+
+.sidebar-nav {
+ flex: 1;
+ padding: 12px 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.sidebar-section {
+ padding: 8px 12px 4px;
+ font-size: 10px;
+ font-weight: 700;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ margin-top: 8px;
+}
+
+.nav-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 12px;
+ border-radius: var(--radius);
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: all 0.15s ease;
+ border: none;
+ background: none;
+ width: 100%;
+ text-align: left;
+ text-decoration: none;
+}
+
+.nav-item:hover {
+ background: var(--bg-hover);
+ color: var(--text-primary);
+}
+
+.nav-item.active {
+ background: var(--accent-dim);
+ color: var(--accent-hover);
+}
+
+.sidebar-footer {
+ padding: 16px;
+ border-top: 1px solid var(--border);
+}
+
+.user-info {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.user-avatar {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, var(--accent), #7c3aed);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+ font-weight: 700;
+ color: #fff;
+ flex-shrink: 0;
+}
+
+.user-name {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text-primary);
+}
+
+.user-role {
+ font-size: 11px;
+ color: var(--text-secondary);
+}
+
+/* ── Main Layout ── */
+.app-layout {
+ display: flex;
+ min-height: 100vh;
+}
+
+.main-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ overflow: hidden;
+}
+
+/* When mail layout is inside main-content, let it fill */
+.main-content > .mail-layout {
+ flex: 1;
+}
+
+.page-header {
+ padding: 24px 32px;
+ border-bottom: 1px solid var(--border);
+ background: var(--bg-card);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.page-title {
+ font-size: 20px;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.page-subtitle {
+ font-size: 13px;
+ color: var(--text-secondary);
+ margin-top: 2px;
+}
+
+.page-body {
+ padding: 32px;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+/* ── Stats Grid ── */
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 16px;
+}
+
+/* ── Modal ── */
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.7);
+ backdrop-filter: blur(4px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 100;
+ padding: 16px;
+ animation: fadeIn 0.15s ease;
+}
+
+.modal {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ width: 100%;
+ max-width: 480px;
+ box-shadow: var(--shadow-lg);
+ animation: slideUp 0.2s ease;
+}
+
+.modal-header {
+ padding: 20px 24px 16px;
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.modal-title {
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.modal-close {
+ background: none;
+ border: none;
+ color: var(--text-secondary);
+ cursor: pointer;
+ padding: 4px;
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: color 0.15s ease;
+}
+
+.modal-close:hover {
+ color: var(--text-primary);
+}
+
+.modal-body {
+ padding: 24px;
+}
+
+.modal-footer {
+ padding: 16px 24px;
+ border-top: 1px solid var(--border);
+ display: flex;
+ gap: 10px;
+ justify-content: flex-end;
+}
+
+/* ── Login ── */
+.login-page {
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--bg);
+ background-image: radial-gradient(ellipse at 50% 0%, rgba(56, 139, 253, 0.08) 0%, transparent 60%);
+ padding: 16px;
+}
+
+.login-box {
+ width: 100%;
+ max-width: 400px;
+}
+
+.login-header {
+ text-align: center;
+ margin-bottom: 32px;
+}
+
+.login-logo {
+ width: 52px;
+ height: 52px;
+ background: linear-gradient(135deg, var(--accent), #7c3aed);
+ border-radius: 14px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin: 0 auto 16px;
+ box-shadow: 0 8px 24px rgba(56, 139, 253, 0.3);
+}
+
+.login-title {
+ font-size: 22px;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.login-sub {
+ font-size: 13px;
+ color: var(--text-secondary);
+ margin-top: 6px;
+}
+
+.error-msg {
+ background: var(--danger-dim);
+ border: 1px solid var(--danger);
+ border-radius: var(--radius);
+ padding: 10px 14px;
+ color: var(--danger);
+ font-size: 13px;
+}
+
+/* ── Search ── */
+.search-bar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.search-input-wrap {
+ position: relative;
+ flex: 1;
+}
+
+.search-icon {
+ position: absolute;
+ left: 10px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--text-muted);
+ pointer-events: none;
+}
+
+.search-input {
+ padding-left: 34px;
+}
+
+/* ── Empty state ── */
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 60px 20px;
+ gap: 12px;
+ color: var(--text-secondary);
+}
+
+.empty-icon {
+ width: 48px;
+ height: 48px;
+ background: var(--bg-hover);
+ border-radius: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-muted);
+}
+
+/* ── Progress bar ── */
+.progress-bar {
+ height: 4px;
+ background: var(--border);
+ border-radius: 2px;
+ overflow: hidden;
+ flex: 1;
+}
+
+.progress-fill {
+ height: 100%;
+ background: var(--accent);
+ border-radius: 2px;
+ transition: width 0.3s ease;
+}
+
+.progress-fill.danger {
+ background: var(--danger);
+}
+
+/* ── Animations ── */
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes slideUp {
+ from { opacity: 0; transform: translateY(12px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+.spinner {
+ width: 16px;
+ height: 16px;
+ border: 2px solid transparent;
+ border-top-color: currentColor;
+ border-radius: 50%;
+ animation: spin 0.6s linear infinite;
+ flex-shrink: 0;
+}
+
+/* ══════════════════════════════════════════════════════════
+ MAIL CLIENT — Gmail-style 3-column
+ ══════════════════════════════════════════════════════════ */
+
+.mail-layout {
+ display: grid;
+ grid-template-columns: 220px 360px 1fr;
+ height: 100%;
+ overflow: hidden;
+}
+
+/* ── Left: Folder Sidebar ── */
+.mail-sidebar {
+ background: var(--bg);
+ border-right: 1px solid var(--border);
+ padding: 16px 10px;
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ gap: 6px;
+}
+.mail-sidebar .btn-primary {
+ border-radius: 20px;
+ padding: 10px 16px;
+ font-size: 13px;
+ font-weight: 600;
+ gap: 6px;
+ box-shadow: 0 2px 8px rgba(88, 166, 255, 0.25);
+}
+
+.mail-account {
+ margin-top: auto;
+ padding: 12px 8px;
+ border-top: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+}
+.mail-account-email {
+ font-size: 11px;
+ color: var(--text-muted);
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Folders */
+.folder-list {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ margin-top: 4px;
+}
+.folder-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 9px 12px;
+ border-radius: 20px;
+ border: none;
+ background: none;
+ cursor: pointer;
+ font-size: 13px;
+ color: var(--text-secondary);
+ text-align: left;
+ width: 100%;
+ transition: all 0.15s ease;
+ font-family: inherit;
+}
+.folder-item:hover {
+ background: var(--bg-hover);
+ color: var(--text-primary);
+}
+.folder-item.active {
+ background: var(--accent-dim);
+ color: var(--accent-hover);
+ font-weight: 600;
+}
+.folder-icon { font-size: 15px; flex-shrink: 0; width: 20px; text-align: center; }
+.folder-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.folder-badge {
+ font-size: 10px;
+ font-weight: 700;
+ min-width: 20px;
+ height: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 10px;
+ background: var(--accent);
+ color: #fff;
+ padding: 0 6px;
+ letter-spacing: 0.02em;
+}
+
+/* ── Center: Message List ── */
+.mail-list {
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ background: var(--bg);
+}
+.mail-list-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 20px;
+ border-bottom: 1px solid var(--border);
+ background: var(--bg-card);
+}
+.mail-list-header h2 {
+ font-size: 15px;
+ font-weight: 700;
+ color: var(--text-primary);
+ margin: 0;
+}
+.message-list-inner {
+ overflow-y: auto;
+ flex: 1;
+}
+.message-row {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 14px 20px;
+ cursor: pointer;
+ border-bottom: 1px solid rgba(48, 54, 61, 0.5);
+ transition: all 0.12s ease;
+ position: relative;
+}
+.message-row:hover {
+ background: var(--bg-hover);
+}
+.message-row.selected {
+ background: var(--accent-dim);
+ border-left: 3px solid var(--accent);
+ padding-left: 17px;
+}
+.message-row.unread::before {
+ content: "";
+ position: absolute;
+ left: 8px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--accent);
+}
+.message-row.unread { padding-left: 22px; }
+.message-row.unread.selected { padding-left: 19px; }
+.message-row.unread .message-sender { font-weight: 700; color: var(--text-primary); }
+.message-row.unread .message-subject { color: var(--text-secondary); font-weight: 500; }
+.message-avatar {
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, var(--accent-dim), var(--bg-hover));
+ color: var(--accent-hover);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ font-weight: 700;
+ flex-shrink: 0;
+ margin-top: 2px;
+}
+.message-content { flex: 1; min-width: 0; }
+.message-top {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 3px;
+ gap: 8px;
+}
+.message-sender {
+ font-size: 13px;
+ color: var(--text-secondary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ flex: 1;
+}
+.message-time {
+ font-size: 11px;
+ color: var(--text-muted);
+ flex-shrink: 0;
+ font-variant-numeric: tabular-nums;
+}
+.message-subject {
+ font-size: 12px;
+ color: var(--text-muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ line-height: 1.5;
+}
+.message-attach {
+ font-size: 13px;
+ flex-shrink: 0;
+ opacity: 0.6;
+ margin-top: 2px;
+}
+
+/* ── Right: Message Detail ── */
+.mail-detail {
+ overflow-y: auto;
+ background: var(--bg-card);
+}
+.message-view {
+ padding: 28px 32px;
+ max-width: 900px;
+}
+.message-view-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 24px;
+ gap: 20px;
+}
+.message-view-subject {
+ font-size: 20px;
+ font-weight: 700;
+ color: var(--text-primary);
+ line-height: 1.35;
+ letter-spacing: -0.01em;
+}
+.message-view-actions { display: flex; gap: 8px; flex-shrink: 0; }
+.message-view-meta {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 16px 0;
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 20px;
+}
+.message-view-avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, var(--accent-dim), var(--bg-hover));
+ color: var(--accent-hover);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ font-weight: 700;
+ flex-shrink: 0;
+}
+.message-attachments {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 20px;
+ padding: 12px 0;
+ border-bottom: 1px solid var(--border);
+}
+.attachment-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 7px 12px;
+ border-radius: 8px;
+ background: var(--bg-hover);
+ border: 1px solid var(--border);
+ font-size: 12px;
+ color: var(--text-secondary);
+ transition: border-color 0.12s;
+ cursor: pointer;
+}
+.attachment-chip:hover { border-color: var(--accent); color: var(--text-primary); }
+.message-view-body {
+ min-height: 200px;
+ padding-top: 4px;
+}
+
+/* ── Empty state for mail ── */
+.mail-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ color: var(--text-muted);
+ gap: 12px;
+ padding: 40px;
+}
+.mail-empty-icon {
+ width: 64px;
+ height: 64px;
+ border-radius: 50%;
+ background: var(--bg-hover);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-bottom: 4px;
+}
+
+/* ── Attachment buttons ── */
+.attachment-actions {
+ display: inline-flex;
+ gap: 4px;
+ margin-left: auto;
+}
+.att-btn {
+ background: none;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 12px;
+ padding: 2px 5px;
+ transition: all 0.12s;
+ color: var(--text-muted);
+}
+.att-btn:hover {
+ border-color: var(--accent);
+ color: var(--accent-hover);
+ background: var(--accent-dim);
+}
+
+/* ── Compose dropzone ── */
+.compose-dropzone {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 16px;
+ border: 2px dashed var(--border);
+ border-radius: var(--radius);
+ cursor: pointer;
+ color: var(--text-muted);
+ font-size: 12px;
+ transition: all 0.15s;
+}
+.compose-dropzone:hover {
+ border-color: var(--text-secondary);
+ color: var(--text-secondary);
+ background: var(--bg-hover);
+}
+.compose-dropzone.active {
+ border-color: var(--accent);
+ color: var(--accent-hover);
+ background: var(--accent-dim);
+}
+
+.compose-attachments {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+.compose-att-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ background: var(--bg-hover);
+ border-radius: var(--radius);
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+.att-remove {
+ margin-left: auto;
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: var(--text-muted);
+ font-size: 12px;
+ padding: 2px 6px;
+ border-radius: 4px;
+ transition: all 0.12s;
+}
+.att-remove:hover {
+ color: var(--danger);
+ background: var(--danger-dim);
+}
+
+/* ── Responsive ── */
+@media (max-width: 1024px) {
+ .mail-layout { grid-template-columns: 60px 280px 1fr; }
+ .mail-sidebar { padding: 12px 6px; }
+ .mail-sidebar .btn-primary { font-size: 0; padding: 10px; justify-content: center; }
+ .folder-name { display: none; }
+ .folder-badge { display: none; }
+ .folder-item { justify-content: center; padding: 8px; border-radius: 12px; }
+ .mail-account-email { display: none; }
+}
+
+@media (max-width: 768px) {
+ .sidebar { display: none; }
+ .mail-layout { grid-template-columns: 1fr; }
+ .mail-sidebar { display: none; }
+ .mail-detail { display: none; }
+ .page-body { padding: 16px; }
+ .page-header { padding: 16px; }
+ .stats-grid { grid-template-columns: 1fr 1fr; }
}
diff --git a/app/layout.tsx b/app/layout.tsx
index 976eb90..e202888 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -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 (
-
- {children}
+
+ {children}
);
}
diff --git a/app/login/page.tsx b/app/login/page.tsx
new file mode 100644
index 0000000..c256ea1
--- /dev/null
+++ b/app/login/page.tsx
@@ -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 (
+
+
+
+
+
AyrisMail Central
+
Mail sunucunuzu kolayca yönetin
+
+
+
+
+
+ AyrisTech © {new Date().getFullYear()} — Tüm hakları saklıdır.
+
+
+
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
index 3f36f7c..dc602ef 100644
--- a/app/page.tsx
+++ b/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 (
-
-
-
-
-
- To get started, edit the page.tsx file.
-
-
- Looking for a starting point or more instructions? Head over to{" "}
-
- Templates
- {" "}
- or the{" "}
-
- Learning
- {" "}
- center.
-
-
-
-
-
- );
+export default async function HomePage() {
+ const session = await auth();
+ if (session) {
+ redirect("/dashboard");
+ } else {
+ redirect("/login");
+ }
}
diff --git a/auth.ts b/auth.ts
new file mode 100644
index 0000000..e88dba6
--- /dev/null
+++ b/auth.ts
@@ -0,0 +1,53 @@
+import NextAuth from "next-auth";
+import Credentials from "next-auth/providers/credentials";
+import { authenticateUser } from "@/lib/users";
+
+export const { handlers, signIn, signOut, auth } = NextAuth({
+ providers: [
+ Credentials({
+ credentials: {
+ email: { label: "E-posta", type: "email" },
+ password: { label: "Şifre", type: "password" },
+ },
+ async authorize(credentials) {
+ const email = credentials?.email as string | undefined;
+ const password = credentials?.password as string | undefined;
+
+ if (!email || !password) return null;
+
+ const user = authenticateUser(email, password);
+ if (!user) return null;
+
+ return {
+ id: user.id,
+ name: user.name,
+ email: user.email,
+ role: user.role,
+ domains: user.domains,
+ };
+ },
+ }),
+ ],
+ callbacks: {
+ async jwt({ token, user }) {
+ if (user) {
+ token.id = user.id;
+ token.role = (user as { role?: string }).role;
+ token.domains = (user as { domains?: string[] }).domains;
+ }
+ return token;
+ },
+ async session({ session, token }) {
+ session.user.id = token.id as string;
+ session.user.role = token.role as string;
+ session.user.domains = token.domains as string[];
+ return session;
+ },
+ },
+ pages: {
+ signIn: "/login",
+ },
+ session: {
+ strategy: "jwt",
+ },
+});
diff --git a/components/Providers.tsx b/components/Providers.tsx
new file mode 100644
index 0000000..0dfb823
--- /dev/null
+++ b/components/Providers.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { SessionProvider } from "next-auth/react";
+
+export default function Providers({ children }: { children: React.ReactNode }) {
+ return {children};
+}
diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx
new file mode 100644
index 0000000..3aa25cd
--- /dev/null
+++ b/components/Sidebar.tsx
@@ -0,0 +1,148 @@
+"use client";
+
+import { useSession, signOut } from "next-auth/react";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+
+const navItems = [
+ {
+ section: "GENEL",
+ items: [
+ { href: "/dashboard", label: "Dashboard", icon: HomeIcon, roles: ["SUPER_ADMIN", "DOMAIN_ADMIN"] },
+ { href: "/dashboard/mail", label: "Mail", icon: InboxIcon, roles: ["SUPER_ADMIN", "DOMAIN_ADMIN"] },
+ ],
+ },
+ {
+ section: "YÖNETİM",
+ items: [
+ { href: "/dashboard/domains", label: "Domainler", icon: GlobeIcon, roles: ["SUPER_ADMIN"] },
+ { href: "/dashboard/users", label: "Kullanıcılar", icon: UsersIcon, roles: ["SUPER_ADMIN"] },
+ { href: "/dashboard/mailboxes", label: "Mail Hesapları", icon: MailIcon, roles: ["SUPER_ADMIN", "DOMAIN_ADMIN"] },
+ ],
+ },
+];
+
+export default function Sidebar() {
+ const { data: session } = useSession();
+ const pathname = usePathname();
+ const role = session?.user?.role ?? "";
+ const name = session?.user?.name ?? "";
+ const email = session?.user?.email ?? "";
+
+ return (
+
+ );
+}
+
+// Icons
+function HomeIcon() {
+ return (
+
+ );
+}
+
+function GlobeIcon() {
+ return (
+
+ );
+}
+
+function UsersIcon() {
+ return (
+
+ );
+}
+
+function MailIcon() {
+ return (
+
+ );
+}
+
+function InboxIcon() {
+ return (
+
+ );
+}
+
+function LogOutIcon() {
+ return (
+
+ );
+}
diff --git a/components/mail/ComposeModal.tsx b/components/mail/ComposeModal.tsx
new file mode 100644
index 0000000..0fc05dc
--- /dev/null
+++ b/components/mail/ComposeModal.tsx
@@ -0,0 +1,165 @@
+"use client";
+import { useState, useRef, useCallback } from "react";
+import type { MailMessage } from "@/app/dashboard/mail/page";
+import { formatBytes } from "@/lib/format";
+
+interface AttachmentFile {
+ file: File;
+ name: string;
+ size: number;
+}
+
+export default function ComposeModal({ replyTo, onClose, onSent }: {
+ replyTo: MailMessage | null;
+ onClose: () => void;
+ onSent: () => void;
+}) {
+ const [to, setTo] = useState(replyTo ? replyTo.from[0]?.address ?? "" : "");
+ const [cc, setCc] = useState("");
+ const [subject, setSubject] = useState(replyTo ? `Re: ${replyTo.subject.replace(/^Re:\s*/i, "")}` : "");
+ const [body, setBody] = useState(replyTo ? `\n\n---\n${replyTo.text?.slice(0, 500) ?? ""}` : "");
+ const [attachments, setAttachments] = useState([]);
+ const [sending, setSending] = useState(false);
+ const [error, setError] = useState("");
+ const [dragOver, setDragOver] = useState(false);
+ const fileInputRef = useRef(null);
+
+ const addFiles = useCallback((files: FileList | null) => {
+ if (!files) return;
+ const newFiles = Array.from(files).map((f) => ({ file: f, name: f.name, size: f.size }));
+ setAttachments((prev) => [...prev, ...newFiles]);
+ }, []);
+
+ const removeAttachment = (index: number) => {
+ setAttachments((prev) => prev.filter((_, i) => i !== index));
+ };
+
+ const handleDrop = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ setDragOver(false);
+ addFiles(e.dataTransfer.files);
+ }, [addFiles]);
+
+ const handleSend = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setSending(true);
+ setError("");
+
+ try {
+ if (attachments.length > 0) {
+ // Use FormData for attachments
+ const formData = new FormData();
+ formData.append("to", to);
+ if (cc) formData.append("cc", cc);
+ formData.append("subject", subject);
+ formData.append("text", body);
+ formData.append("html", `${body.replace(/`);
+ attachments.forEach((att) => {
+ formData.append("attachments", att.file, att.name);
+ });
+
+ const res = await fetch("/api/mail/send", { method: "POST", body: formData });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error || "Gönderilemedi");
+ } else {
+ // JSON for simple messages
+ const res = await fetch("/api/mail/send", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ to, cc: cc || undefined, subject, text: body,
+ html: `${body.replace(/`,
+ }),
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error || "Gönderilemedi");
+ }
+ onSent();
+ } catch (err: any) {
+ setError(err.message);
+ }
+ setSending(false);
+ };
+
+ const totalSize = attachments.reduce((sum, a) => sum + a.size, 0);
+
+ return (
+ e.target === e.currentTarget && onClose()}>
+
+
+
{replyTo ? "Yanıtla" : "Yeni Mail"}
+
+
+
+
+
+ );
+}
+
+function SendIcon() { return ; }
diff --git a/components/mail/FolderList.tsx b/components/mail/FolderList.tsx
new file mode 100644
index 0000000..c954e14
--- /dev/null
+++ b/components/mail/FolderList.tsx
@@ -0,0 +1,66 @@
+"use client";
+import type { MailFolder } from "@/app/dashboard/mail/page";
+
+const FOLDER_ICONS: Record = {
+ "\\Inbox": "📥",
+ "\\Sent": "📤",
+ "\\Drafts": "📝",
+ "\\Trash": "🗑️",
+ "\\Junk": "⚠️",
+ "\\Archive": "📦",
+};
+
+const FOLDER_LABELS: Record = {
+ INBOX: "Gelen Kutusu",
+ Sent: "Gönderilenler",
+ Drafts: "Taslaklar",
+ Trash: "Çöp Kutusu",
+ Junk: "Spam",
+ Archive: "Arşiv",
+};
+
+function getFolderIcon(folder: MailFolder): string {
+ if (folder.specialUse && FOLDER_ICONS[folder.specialUse]) return FOLDER_ICONS[folder.specialUse];
+ const lower = folder.path.toLowerCase();
+ if (lower === "inbox") return "📥";
+ if (lower.includes("sent")) return "📤";
+ if (lower.includes("draft")) return "📝";
+ if (lower.includes("trash")) return "🗑️";
+ if (lower.includes("junk") || lower.includes("spam")) return "⚠️";
+ if (lower.includes("archive")) return "📦";
+ return "📁";
+}
+
+function getFolderLabel(folder: MailFolder): string {
+ return FOLDER_LABELS[folder.name] ?? FOLDER_LABELS[folder.path] ?? folder.name;
+}
+
+export default function FolderList({ folders, active, onSelect }: {
+ folders: MailFolder[];
+ active: string;
+ onSelect: (path: string) => void;
+}) {
+ const sorted = [...folders].sort((a, b) => {
+ if (a.path === "INBOX") return -1;
+ if (b.path === "INBOX") return 1;
+ if (a.specialUse && !b.specialUse) return -1;
+ if (!a.specialUse && b.specialUse) return 1;
+ return a.name.localeCompare(b.name);
+ });
+
+ return (
+
+ {sorted.map((f) => (
+
+ ))}
+
+ );
+}
diff --git a/components/mail/MailLogin.tsx b/components/mail/MailLogin.tsx
new file mode 100644
index 0000000..5660538
--- /dev/null
+++ b/components/mail/MailLogin.tsx
@@ -0,0 +1,60 @@
+"use client";
+import { useState } from "react";
+
+export default function MailLogin({ onSuccess }: { onSuccess: (email: string) => void }) {
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError("");
+ setLoading(true);
+ const res = await fetch("/api/mail/auth", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email, password }),
+ });
+ const data = await res.json();
+ setLoading(false);
+ if (res.ok) {
+ onSuccess(email);
+ } else {
+ setError(data.error || "Bağlantı başarısız");
+ }
+ };
+
+ return (
+
+
+
+
📧
+
Mail Hesabına Bağlan
+
+ Mailcow mail hesabınızın bilgilerini girin
+
+
+
+
+
+ );
+}
diff --git a/components/mail/MessageList.tsx b/components/mail/MessageList.tsx
new file mode 100644
index 0000000..bb6520e
--- /dev/null
+++ b/components/mail/MessageList.tsx
@@ -0,0 +1,65 @@
+"use client";
+import type { MailEnvelope } from "@/app/dashboard/mail/page";
+
+function timeAgo(dateStr: string): string {
+ const now = new Date();
+ const d = new Date(dateStr);
+ const diff = now.getTime() - d.getTime();
+ const mins = Math.floor(diff / 60000);
+ if (mins < 1) return "şimdi";
+ if (mins < 60) return `${mins}dk`;
+ const hrs = Math.floor(mins / 60);
+ if (hrs < 24) return `${hrs}sa`;
+ const days = Math.floor(hrs / 24);
+ if (days < 7) return `${days}g`;
+ return d.toLocaleDateString("tr-TR", { day: "numeric", month: "short" });
+}
+
+function senderName(msg: MailEnvelope): string {
+ const f = msg.from[0];
+ return f?.name || f?.address || "Bilinmeyen";
+}
+
+export default function MessageList({ messages, loading, selectedUid, onSelect, onDelete }: {
+ messages: MailEnvelope[];
+ loading: boolean;
+ selectedUid: number | null;
+ onSelect: (uid: number) => void;
+ onDelete: (uid: number) => void;
+}) {
+ if (loading) {
+ return
;
+ }
+
+ if (messages.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {messages.map((m) => (
+
onSelect(m.uid)}
+ >
+
+ {senderName(m)[0]?.toUpperCase() ?? "?"}
+
+
+
+ {senderName(m)}
+ {timeAgo(m.date)}
+
+
{m.subject}
+
+ {m.hasAttachments &&
📎}
+
+ ))}
+
+ );
+}
diff --git a/components/mail/MessageView.tsx b/components/mail/MessageView.tsx
new file mode 100644
index 0000000..412b78b
--- /dev/null
+++ b/components/mail/MessageView.tsx
@@ -0,0 +1,147 @@
+"use client";
+import type { MailMessage } from "@/app/dashboard/mail/page";
+import { formatBytes } from "@/lib/format";
+
+function getFileIcon(contentType: string, filename: string): string {
+ if (contentType.startsWith("image/")) return "🖼️";
+ if (contentType === "application/pdf") return "📄";
+ if (contentType.includes("zip") || contentType.includes("rar") || contentType.includes("tar")) return "📦";
+ if (contentType.includes("word") || filename.endsWith(".doc") || filename.endsWith(".docx")) return "📝";
+ if (contentType.includes("sheet") || contentType.includes("excel") || filename.endsWith(".xls")) return "📊";
+ if (contentType.includes("presentation") || filename.endsWith(".ppt")) return "📑";
+ if (contentType.startsWith("video/")) return "🎬";
+ if (contentType.startsWith("audio/")) return "🎵";
+ return "📎";
+}
+
+function canPreview(contentType: string): boolean {
+ return contentType.startsWith("image/") || contentType === "application/pdf";
+}
+
+export default function MessageView({ message, onReply, onDelete, folder }: {
+ message: MailMessage;
+ onReply: () => void;
+ onDelete: () => void;
+ folder: string;
+}) {
+ const from = message.from[0];
+ const date = new Date(message.date).toLocaleString("tr-TR", {
+ day: "numeric", month: "long", year: "numeric", hour: "2-digit", minute: "2-digit",
+ });
+
+ const downloadUrl = (filename: string) =>
+ `/api/mail/messages/${message.uid}/attachments?folder=${encodeURIComponent(folder)}&filename=${encodeURIComponent(filename)}`;
+
+ const handleAttachment = (att: { filename: string; contentType: string }, preview: boolean) => {
+ const url = downloadUrl(att.filename);
+ if (preview && canPreview(att.contentType)) {
+ window.open(url, "_blank");
+ } else {
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = att.filename;
+ a.click();
+ }
+ };
+
+ return (
+
+ {/* Header */}
+
+
{message.subject}
+
+
+
+
+
+
+ {/* Meta */}
+
+
{(from?.name || from?.address)?.[0]?.toUpperCase() ?? "?"}
+
+
{from?.name || from?.address}
+
+ {from?.address}
+ {message.to.length > 0 && <> → {message.to.map((t) => t.address).join(", ")}>}
+
+
+
{date}
+
+
+ {/* Attachments */}
+ {message.attachments.length > 0 && (
+
+
+ 📎 {message.attachments.length} ek
+
+ {message.attachments.map((att, i) => (
+
handleAttachment(att, false)}>
+ {getFileIcon(att.contentType, att.filename)}
+
+ {att.filename}
+
+ {formatBytes(att.size)}
+
+
+ {canPreview(att.contentType) && (
+
+ )}
+
+
+ ))}
+
+ )}
+
+ {/* Body */}
+
+
+ );
+}
+
+function sanitizeHtml(html: string): string {
+ const style = ``;
+ let clean = html.replace(/