From 4a9196f48340680008697b87879e7cd7e7e9f77c Mon Sep 17 00:00:00 2001 From: AyrisAI Date: Thu, 14 May 2026 01:57:52 +0300 Subject: [PATCH] first commit --- .gitignore | 2 + Dockerfile | 54 + app/api/auth/[...nextauth]/route.ts | 3 + app/api/domains/[domain]/route.ts | 18 + app/api/domains/route.ts | 34 + app/api/mail/auth/route.ts | 49 + app/api/mail/folders/route.ts | 23 + .../mail/messages/[uid]/attachments/route.ts | 44 + app/api/mail/messages/[uid]/route.ts | 32 + app/api/mail/messages/route.ts | 59 + app/api/mail/send/route.ts | 64 + app/api/mailboxes/[email]/route.ts | 55 + app/api/mailboxes/route.ts | 40 + app/api/users/route.ts | 21 + app/dashboard/domains/page.tsx | 232 + app/dashboard/layout.tsx | 22 + app/dashboard/mail/page.tsx | 200 + app/dashboard/mailboxes/page.tsx | 382 + app/dashboard/page.tsx | 211 + app/dashboard/users/page.tsx | 142 + app/globals.css | 1113 ++- app/layout.tsx | 22 +- app/login/page.tsx | 90 + app/page.tsx | 72 +- auth.ts | 53 + components/Providers.tsx | 7 + components/Sidebar.tsx | 148 + components/mail/ComposeModal.tsx | 165 + components/mail/FolderList.tsx | 66 + components/mail/MailLogin.tsx | 60 + components/mail/MessageList.tsx | 65 + components/mail/MessageView.tsx | 147 + dev.db | Bin 0 -> 36864 bytes docs/openapi.yaml | 6180 +++++++++++++++++ docs/prd.md | 62 + docs/prd2.md | 49 + lib/format.ts | 13 + lib/imap.ts | 339 + lib/mail-session.ts | 56 + lib/mailcow.ts | 211 + lib/smtp.ts | 131 + lib/users.ts | 69 + next.config.ts | 2 +- package-lock.json | 1268 +++- package.json | 21 +- proxy.ts | 22 + types/next-auth.d.ts | 27 + 47 files changed, 12043 insertions(+), 102 deletions(-) create mode 100644 Dockerfile create mode 100644 app/api/auth/[...nextauth]/route.ts create mode 100644 app/api/domains/[domain]/route.ts create mode 100644 app/api/domains/route.ts create mode 100644 app/api/mail/auth/route.ts create mode 100644 app/api/mail/folders/route.ts create mode 100644 app/api/mail/messages/[uid]/attachments/route.ts create mode 100644 app/api/mail/messages/[uid]/route.ts create mode 100644 app/api/mail/messages/route.ts create mode 100644 app/api/mail/send/route.ts create mode 100644 app/api/mailboxes/[email]/route.ts create mode 100644 app/api/mailboxes/route.ts create mode 100644 app/api/users/route.ts create mode 100644 app/dashboard/domains/page.tsx create mode 100644 app/dashboard/layout.tsx create mode 100644 app/dashboard/mail/page.tsx create mode 100644 app/dashboard/mailboxes/page.tsx create mode 100644 app/dashboard/page.tsx create mode 100644 app/dashboard/users/page.tsx create mode 100644 app/login/page.tsx create mode 100644 auth.ts create mode 100644 components/Providers.tsx create mode 100644 components/Sidebar.tsx create mode 100644 components/mail/ComposeModal.tsx create mode 100644 components/mail/FolderList.tsx create mode 100644 components/mail/MailLogin.tsx create mode 100644 components/mail/MessageList.tsx create mode 100644 components/mail/MessageView.tsx create mode 100644 dev.db create mode 100644 docs/openapi.yaml create mode 100644 docs/prd.md create mode 100644 docs/prd2.md create mode 100644 lib/format.ts create mode 100644 lib/imap.ts create mode 100644 lib/mail-session.ts create mode 100644 lib/mailcow.ts create mode 100644 lib/smtp.ts create mode 100644 lib/users.ts create mode 100644 proxy.ts create mode 100644 types/next-auth.d.ts 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.
+
+ ) : ( + + + + + + + + + + + + + {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 ( + + + + + + + + + ); + })} + +
DomainMail KutularıAliasKotaDurumİşlemler
+
+
+ +
+
+
{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

+ +
+
+
+ {formError &&
{formError}
} +
+ + setForm({ ...form, domain: e.target.value })} required /> +
+
+ + setForm({ ...form, description: e.target.value })} /> +
+
+
+ + setForm({ ...form, mailboxes: e.target.value })} /> +
+
+ + setForm({ ...form, quota: e.target.value })} /> +
+
+
+
+ + +
+
+
+
+ )} + + ); +} + +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 ( + +
+ +
{children}
+
+
+ ); +} 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); }} + /> +
+
{email}
+ +
+
+ +
+
+

+ {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"} +
+
+ ) : ( + + + + + + + + + + + + {filtered.map((m) => { + const usedPct = m.quota > 0 ? Math.min((m.quota_used / m.quota) * 100, 100) : 0; + return ( + + + + + + + + ); + })} + +
E-postaAd SoyadKotaDurumİşlemler
+
+
+ {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ı

+ +
+
+
+ {formError &&
{formError}
} +
+ +
+ setCreateForm({ ...createForm, local_part: e.target.value })} + required + /> + + @{selectedDomain} + +
+
+
+ + setCreateForm({ ...createForm, name: e.target.value })} + required /> +
+
+ + setCreateForm({ ...createForm, password: e.target.value })} + required /> +
+
+ + setCreateForm({ ...createForm, quota: parseInt(e.target.value) || 3072 })} /> +
+
+
+ + +
+
+
+
+ )} + + {/* Password Modal */} + {showPasswordModal && ( +
e.target === e.currentTarget && setShowPasswordModal(null)}> +
+
+

Şifre Değiştir

+ +
+
+
+
+ {showPasswordModal} için yeni şifre +
+
+ + setNewPassword(e.target.value)} + required autoFocus /> +
+
+
+ + +
+
+
+
+ )} + + ); +} + +// 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 +

+
+
+ + + + + + + + + + + {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 ( + + + + + + + ); + })} + +
DomainMail KutularıKota KullanımıDurum
+
+
+ +
+ {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} +
+
+
{title}
+
{desc}
+
+ + + +
+ ); +} + +// 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ı
+
+ ) : ( + + + + + + + + + + {filtered.map((u) => ( + + + + + + ))} + +
KullanıcıRolİzin Verilen Domainler
+
+
+ {u.name[0]?.toUpperCase()} +
+
+
{u.name}
+
{u.email}
+
+
+
+ + {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

+
+ +
+
+ {error &&
{error}
} +
+ + setEmail(e.target.value)} + required + autoComplete="email" + /> +
+
+ + setPassword(e.target.value)} + required + autoComplete="current-password" + /> +
+ +
+
+ +

+ 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 ( -
-
- Next.js logo -
-

- 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"}

+ +
+
+
+ {error &&
{error}
} +
+ + setTo(e.target.value)} required autoFocus /> +
+
+ + setCc(e.target.value)} /> +
+
+ + setSubject(e.target.value)} required /> +
+
+ +