first commit
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -39,3 +39,5 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
/app/generated/prisma
|
||||
|
||||
54
Dockerfile
Normal file
54
Dockerfile
Normal file
@@ -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"]
|
||||
3
app/api/auth/[...nextauth]/route.ts
Normal file
3
app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
18
app/api/domains/[domain]/route.ts
Normal file
18
app/api/domains/[domain]/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { deleteDomain } from "@/lib/mailcow";
|
||||
|
||||
// DELETE /api/domains/[domain] — super admin only
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ domain: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session || session.user.role !== "SUPER_ADMIN") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { domain } = await params;
|
||||
const result = await deleteDomain(decodeURIComponent(domain));
|
||||
return NextResponse.json(result.data, { status: result.ok ? 200 : 502 });
|
||||
}
|
||||
34
app/api/domains/route.ts
Normal file
34
app/api/domains/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { getDomains, createDomain } from "@/lib/mailcow";
|
||||
import { canAccessDomain } from "@/lib/users";
|
||||
|
||||
// GET /api/domains — list domains (filtered by session)
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const allDomains = await getDomains();
|
||||
const userDomains = session.user.domains ?? [];
|
||||
|
||||
// Super admin sees all, domain admin only sees their domains
|
||||
const visible = allDomains.filter((d) => canAccessDomain(userDomains, d.domain_name));
|
||||
|
||||
return NextResponse.json(visible);
|
||||
}
|
||||
|
||||
// POST /api/domains — create domain (super admin only)
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session || session.user.role !== "SUPER_ADMIN") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { domain, description, mailboxes, quota, maxquota } = body;
|
||||
|
||||
if (!domain) return NextResponse.json({ error: "domain gerekli" }, { status: 400 });
|
||||
|
||||
const result = await createDomain({ domain, description, mailboxes, quota, maxquota });
|
||||
return NextResponse.json(result.data, { status: result.ok ? 200 : 502 });
|
||||
}
|
||||
49
app/api/mail/auth/route.ts
Normal file
49
app/api/mail/auth/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { getMailSession, setMailSession, clearMailSession } from "@/lib/mail-session";
|
||||
import { listFolders } from "@/lib/imap";
|
||||
|
||||
// POST /api/mail/auth — login to mailbox (store creds in cookie)
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { email, password } = await req.json();
|
||||
if (!email || !password) {
|
||||
return NextResponse.json({ error: "Email ve şifre gerekli" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Test the credentials by listing folders
|
||||
try {
|
||||
await listFolders({ email, password });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: "IMAP bağlantısı başarısız: " + (err?.message ?? "Bilinmeyen hata") },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
await setMailSession({ email, password });
|
||||
return NextResponse.json({ success: true, email });
|
||||
}
|
||||
|
||||
// GET /api/mail/auth — check if mail session exists
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const mailSession = await getMailSession();
|
||||
if (!mailSession) {
|
||||
return NextResponse.json({ connected: false });
|
||||
}
|
||||
return NextResponse.json({ connected: true, email: mailSession.email });
|
||||
}
|
||||
|
||||
// DELETE /api/mail/auth — logout from mailbox
|
||||
export async function DELETE() {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
await clearMailSession();
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
23
app/api/mail/folders/route.ts
Normal file
23
app/api/mail/folders/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { getMailSession } from "@/lib/mail-session";
|
||||
import { listFolders } from "@/lib/imap";
|
||||
|
||||
// GET /api/mail/folders
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const creds = await getMailSession();
|
||||
if (!creds) return NextResponse.json({ error: "Mail oturumu yok" }, { status: 401 });
|
||||
|
||||
try {
|
||||
const folders = await listFolders(creds);
|
||||
return NextResponse.json(folders);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: "Klasörler alınamadı: " + (err?.message ?? "") },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
44
app/api/mail/messages/[uid]/attachments/route.ts
Normal file
44
app/api/mail/messages/[uid]/attachments/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { getMailSession } from "@/lib/mail-session";
|
||||
import { getAttachment } from "@/lib/imap";
|
||||
|
||||
// GET /api/mail/messages/[uid]/attachments?folder=INBOX&filename=doc.pdf
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ uid: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const creds = await getMailSession();
|
||||
if (!creds) return NextResponse.json({ error: "Mail oturumu yok" }, { status: 401 });
|
||||
|
||||
const { uid } = await params;
|
||||
const folder = req.nextUrl.searchParams.get("folder") ?? "INBOX";
|
||||
const filename = req.nextUrl.searchParams.get("filename");
|
||||
|
||||
if (!filename) {
|
||||
return NextResponse.json({ error: "Dosya adı gerekli" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const att = await getAttachment(creds, folder, parseInt(uid), filename);
|
||||
if (!att) {
|
||||
return NextResponse.json({ error: "Ek bulunamadı" }, { status: 404 });
|
||||
}
|
||||
|
||||
return new NextResponse(att.content, {
|
||||
headers: {
|
||||
"Content-Type": att.contentType,
|
||||
"Content-Disposition": `attachment; filename="${encodeURIComponent(filename)}"`,
|
||||
"Content-Length": String(att.content.length),
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: "Ek indirilemedi: " + (err?.message ?? "") },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
32
app/api/mail/messages/[uid]/route.ts
Normal file
32
app/api/mail/messages/[uid]/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { getMailSession } from "@/lib/mail-session";
|
||||
import { getMessage } from "@/lib/imap";
|
||||
|
||||
// GET /api/mail/messages/[uid]?folder=INBOX
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ uid: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const creds = await getMailSession();
|
||||
if (!creds) return NextResponse.json({ error: "Mail oturumu yok" }, { status: 401 });
|
||||
|
||||
const { uid } = await params;
|
||||
const folder = req.nextUrl.searchParams.get("folder") ?? "INBOX";
|
||||
|
||||
try {
|
||||
const message = await getMessage(creds, folder, parseInt(uid));
|
||||
if (!message) {
|
||||
return NextResponse.json({ error: "Mesaj bulunamadı" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json(message);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: "Mesaj alınamadı: " + (err?.message ?? "") },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
59
app/api/mail/messages/route.ts
Normal file
59
app/api/mail/messages/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { getMailSession } from "@/lib/mail-session";
|
||||
import { listMessages, deleteMessage, moveMessage, toggleFlag } from "@/lib/imap";
|
||||
|
||||
// GET /api/mail/messages?folder=INBOX&page=1
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const creds = await getMailSession();
|
||||
if (!creds) return NextResponse.json({ error: "Mail oturumu yok" }, { status: 401 });
|
||||
|
||||
const folder = req.nextUrl.searchParams.get("folder") ?? "INBOX";
|
||||
const page = parseInt(req.nextUrl.searchParams.get("page") ?? "1");
|
||||
|
||||
try {
|
||||
const result = await listMessages(creds, folder, page, 50);
|
||||
return NextResponse.json(result);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: "Mesajlar alınamadı: " + (err?.message ?? "") },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/mail/messages — actions: delete, move, flag
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const creds = await getMailSession();
|
||||
if (!creds) return NextResponse.json({ error: "Mail oturumu yok" }, { status: 401 });
|
||||
|
||||
const { action, folder, uid, toFolder, flag, add } = await req.json();
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case "delete":
|
||||
await deleteMessage(creds, folder, uid);
|
||||
break;
|
||||
case "move":
|
||||
await moveMessage(creds, folder, uid, toFolder);
|
||||
break;
|
||||
case "flag":
|
||||
await toggleFlag(creds, folder, uid, flag, add);
|
||||
break;
|
||||
default:
|
||||
return NextResponse.json({ error: "Bilinmeyen işlem" }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: "İşlem başarısız: " + (err?.message ?? "") },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
64
app/api/mail/send/route.ts
Normal file
64
app/api/mail/send/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { getMailSession } from "@/lib/mail-session";
|
||||
import { sendMail } from "@/lib/smtp";
|
||||
|
||||
// POST /api/mail/send — supports both JSON and FormData (for attachments)
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const creds = await getMailSession();
|
||||
if (!creds) return NextResponse.json({ error: "Mail oturumu yok" }, { status: 401 });
|
||||
|
||||
const contentType = req.headers.get("content-type") ?? "";
|
||||
|
||||
let to: string, cc: string | undefined, subject: string, html: string, text: string | undefined;
|
||||
let attachments: { filename: string; content: Buffer; contentType?: string }[] = [];
|
||||
|
||||
if (contentType.includes("multipart/form-data")) {
|
||||
// FormData — with attachments
|
||||
const formData = await req.formData();
|
||||
to = formData.get("to") as string;
|
||||
cc = (formData.get("cc") as string) || undefined;
|
||||
subject = formData.get("subject") as string;
|
||||
html = (formData.get("html") as string) || "";
|
||||
text = (formData.get("text") as string) || undefined;
|
||||
|
||||
const files = formData.getAll("attachments") as File[];
|
||||
for (const file of files) {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
attachments.push({
|
||||
filename: file.name,
|
||||
content: buffer,
|
||||
contentType: file.type || undefined,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// JSON — simple message
|
||||
const body = await req.json();
|
||||
to = body.to;
|
||||
cc = body.cc || undefined;
|
||||
subject = body.subject;
|
||||
html = body.html || `<p>${body.text || ""}</p>`;
|
||||
text = body.text || undefined;
|
||||
}
|
||||
|
||||
if (!to || !subject) {
|
||||
return NextResponse.json({ error: "Alıcı ve konu gerekli" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await sendMail(creds, {
|
||||
to,
|
||||
cc,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
return NextResponse.json({ success: true, messageId: result.messageId });
|
||||
}
|
||||
return NextResponse.json({ error: result.error }, { status: 502 });
|
||||
}
|
||||
55
app/api/mailboxes/[email]/route.ts
Normal file
55
app/api/mailboxes/[email]/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { deleteMailbox, editMailbox, getMailboxes } from "@/lib/mailcow";
|
||||
import { canAccessDomain } from "@/lib/users";
|
||||
|
||||
async function checkAccess(session: Awaited<ReturnType<typeof auth>>, email: string) {
|
||||
if (!session) return false;
|
||||
const domain = email.split("@")[1];
|
||||
return canAccessDomain(session.user.domains ?? [], domain);
|
||||
}
|
||||
|
||||
// DELETE /api/mailboxes/[email]
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ email: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
const { email } = await params;
|
||||
const decoded = decodeURIComponent(email);
|
||||
|
||||
if (!(await checkAccess(session, decoded))) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const result = await deleteMailbox([decoded]);
|
||||
return NextResponse.json(result.data, { status: result.ok ? 200 : 502 });
|
||||
}
|
||||
|
||||
// PATCH /api/mailboxes/[email] — password change or toggle active
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ email: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
const { email } = await params;
|
||||
const decoded = decodeURIComponent(email);
|
||||
|
||||
if (!(await checkAccess(session, decoded))) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const attr: Parameters<typeof editMailbox>[1] = {};
|
||||
|
||||
if (body.password) {
|
||||
attr.password = body.password;
|
||||
attr.password2 = body.password;
|
||||
}
|
||||
if (typeof body.active === "number") {
|
||||
attr.active = body.active;
|
||||
}
|
||||
|
||||
const result = await editMailbox([decoded], attr);
|
||||
return NextResponse.json(result.data, { status: result.ok ? 200 : 502 });
|
||||
}
|
||||
40
app/api/mailboxes/route.ts
Normal file
40
app/api/mailboxes/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { getMailboxes, createMailbox } from "@/lib/mailcow";
|
||||
import { canAccessDomain } from "@/lib/users";
|
||||
|
||||
// GET /api/mailboxes?domain=example.com
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const domain = req.nextUrl.searchParams.get("domain");
|
||||
if (!domain) return NextResponse.json({ error: "domain parametresi gerekli" }, { status: 400 });
|
||||
|
||||
if (!canAccessDomain(session.user.domains ?? [], domain)) {
|
||||
return NextResponse.json({ error: "Bu domaine erişim yetkiniz yok" }, { status: 403 });
|
||||
}
|
||||
|
||||
const mailboxes = await getMailboxes(domain);
|
||||
return NextResponse.json(mailboxes);
|
||||
}
|
||||
|
||||
// POST /api/mailboxes
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const body = await req.json();
|
||||
const { local_part, domain, name, password, quota } = body;
|
||||
|
||||
if (!local_part || !domain || !name || !password) {
|
||||
return NextResponse.json({ error: "Eksik alan" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!canAccessDomain(session.user.domains ?? [], domain)) {
|
||||
return NextResponse.json({ error: "Bu domaine erişim yetkiniz yok" }, { status: 403 });
|
||||
}
|
||||
|
||||
const result = await createMailbox({ local_part, domain, name, password, quota });
|
||||
return NextResponse.json(result.data, { status: result.ok ? 200 : 502 });
|
||||
}
|
||||
21
app/api/users/route.ts
Normal file
21
app/api/users/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { getUsers } from "@/lib/users";
|
||||
|
||||
// GET /api/users — super admin only, lists env-defined users (no passwords)
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session || session.user.role !== "SUPER_ADMIN") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const users = getUsers().map(({ id, name, email, role, domains }) => ({
|
||||
id,
|
||||
name,
|
||||
email,
|
||||
role,
|
||||
domains,
|
||||
}));
|
||||
|
||||
return NextResponse.json(users);
|
||||
}
|
||||
232
app/dashboard/domains/page.tsx
Normal file
232
app/dashboard/domains/page.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useTransition } from "react";
|
||||
import { formatBytes } from "@/lib/format";
|
||||
|
||||
interface Domain {
|
||||
domain_name: string;
|
||||
description: string;
|
||||
active: string;
|
||||
mboxes_in_domain: number;
|
||||
mboxes_left: number;
|
||||
max_num_mboxes_for_domain: number;
|
||||
aliases_in_domain: number;
|
||||
quota_used_in_domain: string;
|
||||
max_quota_for_domain: number;
|
||||
}
|
||||
|
||||
export default function DomainsPage() {
|
||||
const [domains, setDomains] = useState<Domain[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [search, setSearch] = useState("");
|
||||
const [form, setForm] = useState({ domain: "", description: "", mailboxes: "10", quota: "10240", maxquota: "10240" });
|
||||
const [formError, setFormError] = useState("");
|
||||
|
||||
const fetchDomains = async () => {
|
||||
setLoading(true);
|
||||
const res = await fetch("/api/domains");
|
||||
if (res.ok) setDomains(await res.json());
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { fetchDomains(); }, []);
|
||||
|
||||
const handleCreate = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError("");
|
||||
startTransition(async () => {
|
||||
const res = await fetch("/api/domains", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
domain: form.domain,
|
||||
description: form.description,
|
||||
mailboxes: parseInt(form.mailboxes),
|
||||
quota: parseInt(form.quota),
|
||||
maxquota: parseInt(form.maxquota),
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
setShowModal(false);
|
||||
setForm({ domain: "", description: "", mailboxes: "10", quota: "10240", maxquota: "10240" });
|
||||
fetchDomains();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
const msg = Array.isArray(data) ? data.map((d: { msg?: string }) => d.msg).join(", ") : (data?.error ?? "Bir hata oluştu");
|
||||
setFormError(String(msg));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (domain: string) => {
|
||||
if (!confirm(`"${domain}" domainini Mailcow'dan silmek istediğinizden emin misiniz?\n\nBu işlem geri alınamaz!`)) return;
|
||||
startTransition(async () => {
|
||||
await fetch(`/api/domains/${encodeURIComponent(domain)}`, { method: "DELETE" });
|
||||
fetchDomains();
|
||||
});
|
||||
};
|
||||
|
||||
const filtered = domains.filter(
|
||||
(d) =>
|
||||
d.domain_name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
d.description?.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Domainler</h1>
|
||||
<p className="page-subtitle">Mailcow üzerindeki tüm domainleri yönetin</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => setShowModal(true)}>
|
||||
<PlusIcon /> Domain Ekle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="page-body">
|
||||
<div className="search-bar">
|
||||
<div className="search-input-wrap">
|
||||
<span className="search-icon"><SearchIcon /></span>
|
||||
<input
|
||||
type="text"
|
||||
className="input search-input"
|
||||
placeholder="Domain veya açıklama ara..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-ghost" onClick={fetchDomains}>
|
||||
<RefreshIcon /> Yenile
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="table-wrap">
|
||||
{loading ? (
|
||||
<div className="empty-state"><span className="spinner" style={{ width: 24, height: 24 }} /></div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon"><GlobeIcon size={24} /></div>
|
||||
<div style={{ fontWeight: 600 }}>Domain bulunamadı</div>
|
||||
<div style={{ fontSize: 12 }}>Mailcow'a domain eklemek için "Domain Ekle" butonuna tıklayın.</div>
|
||||
</div>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<th>Mail Kutuları</th>
|
||||
<th>Alias</th>
|
||||
<th>Kota</th>
|
||||
<th>Durum</th>
|
||||
<th>İşlemler</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((d) => {
|
||||
const quotaUsed = Number(d.quota_used_in_domain);
|
||||
const quotaTotal = d.max_quota_for_domain;
|
||||
const pct = quotaTotal > 0 ? Math.min((quotaUsed / quotaTotal) * 100, 100) : 0;
|
||||
return (
|
||||
<tr key={d.domain_name}>
|
||||
<td>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: 6, background: "var(--accent-dim)", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--accent-hover)", flexShrink: 0 }}>
|
||||
<GlobeIcon />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{d.domain_name}</div>
|
||||
{d.description && <div style={{ fontSize: 11, color: "var(--text-secondary)" }}>{d.description}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{d.mboxes_in_domain}
|
||||
<span style={{ color: "var(--text-muted)", fontSize: 12 }}> / {d.max_num_mboxes_for_domain}</span>
|
||||
</td>
|
||||
<td>{d.aliases_in_domain}</td>
|
||||
<td style={{ minWidth: 140 }}>
|
||||
<div style={{ fontSize: 11, color: "var(--text-secondary)", marginBottom: 4 }}>
|
||||
{formatBytes(quotaUsed)} / {formatBytes(quotaTotal)}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<div className="progress-bar">
|
||||
<div className={`progress-fill ${pct > 80 ? "danger" : ""}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: "var(--text-muted)" }}>{Math.round(pct)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${String(d.active) === "1" ? "badge-green" : "badge-red"}`}>
|
||||
{String(d.active) === "1" ? "● Aktif" : "● Pasif"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(d.domain_name)} disabled={isPending}>
|
||||
<TrashIcon /> Sil
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && setShowModal(false)}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">Mailcow'a Domain Ekle</h2>
|
||||
<button className="modal-close" onClick={() => setShowModal(false)}><XIcon /></button>
|
||||
</div>
|
||||
<form onSubmit={handleCreate}>
|
||||
<div className="modal-body form-group">
|
||||
{formError && <div className="error-msg">{formError}</div>}
|
||||
<div>
|
||||
<label className="label">Domain Adı</label>
|
||||
<input type="text" className="input" placeholder="ornek.com" value={form.domain}
|
||||
onChange={(e) => setForm({ ...form, domain: e.target.value })} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Açıklama (isteğe bağlı)</label>
|
||||
<input type="text" className="input" placeholder="Bu domain hakkında..." value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div>
|
||||
<label className="label">Maks. Mailbox</label>
|
||||
<input type="number" className="input" value={form.mailboxes} min={1}
|
||||
onChange={(e) => setForm({ ...form, mailboxes: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Toplam Kota (MB)</label>
|
||||
<input type="number" className="input" value={form.quota} min={1}
|
||||
onChange={(e) => setForm({ ...form, quota: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setShowModal(false)}>İptal</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={isPending}>
|
||||
{isPending ? <span className="spinner" /> : <PlusIcon />} Ekle
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PlusIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>; }
|
||||
function SearchIcon() { return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>; }
|
||||
function RefreshIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/></svg>; }
|
||||
function GlobeIcon({ size = 13 }: { size?: number }) { return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/><path d="M2 12h20"/></svg>; }
|
||||
function TrashIcon() { return <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>; }
|
||||
function XIcon() { return <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>; }
|
||||
22
app/dashboard/layout.tsx
Normal file
22
app/dashboard/layout.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import Providers from "@/components/Providers";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session) redirect("/login");
|
||||
|
||||
return (
|
||||
<Providers>
|
||||
<div className="app-layout">
|
||||
<Sidebar />
|
||||
<div className="main-content">{children}</div>
|
||||
</div>
|
||||
</Providers>
|
||||
);
|
||||
}
|
||||
200
app/dashboard/mail/page.tsx
Normal file
200
app/dashboard/mail/page.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import MailLogin from "@/components/mail/MailLogin";
|
||||
import FolderList from "@/components/mail/FolderList";
|
||||
import MessageList from "@/components/mail/MessageList";
|
||||
import MessageView from "@/components/mail/MessageView";
|
||||
import ComposeModal from "@/components/mail/ComposeModal";
|
||||
|
||||
export interface MailFolder {
|
||||
name: string;
|
||||
path: string;
|
||||
specialUse?: string;
|
||||
messages: number;
|
||||
unseen: number;
|
||||
}
|
||||
|
||||
export interface MailEnvelope {
|
||||
uid: number;
|
||||
subject: string;
|
||||
from: { name: string; address: string }[];
|
||||
to: { name: string; address: string }[];
|
||||
date: string;
|
||||
seen: boolean;
|
||||
flagged: boolean;
|
||||
hasAttachments: boolean;
|
||||
}
|
||||
|
||||
export interface MailMessage extends MailEnvelope {
|
||||
cc: { name: string; address: string }[];
|
||||
html: string;
|
||||
text: string;
|
||||
attachments: { filename: string; contentType: string; size: number }[];
|
||||
}
|
||||
|
||||
export default function MailPage() {
|
||||
const [connected, setConnected] = useState<boolean | null>(null);
|
||||
const [email, setEmail] = useState("");
|
||||
const [folders, setFolders] = useState<MailFolder[]>([]);
|
||||
const [activeFolder, setActiveFolder] = useState("INBOX");
|
||||
const [messages, setMessages] = useState<MailEnvelope[]>([]);
|
||||
const [selectedUid, setSelectedUid] = useState<number | null>(null);
|
||||
const [openMessage, setOpenMessage] = useState<MailMessage | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCompose, setShowCompose] = useState(false);
|
||||
const [replyTo, setReplyTo] = useState<MailMessage | null>(null);
|
||||
|
||||
// Check connection
|
||||
useEffect(() => {
|
||||
fetch("/api/mail/auth")
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
setConnected(d.connected);
|
||||
if (d.email) setEmail(d.email);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Load folders
|
||||
const loadFolders = useCallback(async () => {
|
||||
const res = await fetch("/api/mail/folders");
|
||||
if (res.ok) setFolders(await res.json());
|
||||
}, []);
|
||||
|
||||
// Load messages
|
||||
const loadMessages = useCallback(async (folder: string) => {
|
||||
setLoading(true);
|
||||
const res = await fetch(`/api/mail/messages?folder=${encodeURIComponent(folder)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setMessages(data.messages ?? []);
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (connected) {
|
||||
loadFolders();
|
||||
loadMessages(activeFolder);
|
||||
}
|
||||
}, [connected, activeFolder, loadFolders, loadMessages]);
|
||||
|
||||
// Open message
|
||||
const openMsg = async (uid: number) => {
|
||||
setSelectedUid(uid);
|
||||
const res = await fetch(`/api/mail/messages/${uid}?folder=${encodeURIComponent(activeFolder)}`);
|
||||
if (res.ok) {
|
||||
const msg = await res.json();
|
||||
setOpenMessage(msg);
|
||||
// Mark as read in list
|
||||
setMessages((prev) => prev.map((m) => m.uid === uid ? { ...m, seen: true } : m));
|
||||
}
|
||||
};
|
||||
|
||||
// Delete
|
||||
const handleDelete = async (uid: number) => {
|
||||
await fetch("/api/mail/messages", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "delete", folder: activeFolder, uid }),
|
||||
});
|
||||
setMessages((prev) => prev.filter((m) => m.uid !== uid));
|
||||
if (selectedUid === uid) { setSelectedUid(null); setOpenMessage(null); }
|
||||
};
|
||||
|
||||
// Reply
|
||||
const handleReply = (msg: MailMessage) => {
|
||||
setReplyTo(msg);
|
||||
setShowCompose(true);
|
||||
};
|
||||
|
||||
// Disconnect
|
||||
const handleDisconnect = async () => {
|
||||
await fetch("/api/mail/auth", { method: "DELETE" });
|
||||
setConnected(false);
|
||||
setEmail("");
|
||||
setFolders([]);
|
||||
setMessages([]);
|
||||
setOpenMessage(null);
|
||||
};
|
||||
|
||||
if (connected === null) {
|
||||
return <div className="empty-state"><span className="spinner" style={{ width: 24, height: 24 }} /></div>;
|
||||
}
|
||||
|
||||
if (!connected) {
|
||||
return <MailLogin onSuccess={(e) => { setConnected(true); setEmail(e); }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mail-layout">
|
||||
<div className="mail-sidebar">
|
||||
<button className="btn btn-primary" style={{ width: "100%" }} onClick={() => { setReplyTo(null); setShowCompose(true); }}>
|
||||
<ComposeIcon /> Yeni Mail
|
||||
</button>
|
||||
<FolderList
|
||||
folders={folders}
|
||||
active={activeFolder}
|
||||
onSelect={(f) => { setActiveFolder(f); setSelectedUid(null); setOpenMessage(null); }}
|
||||
/>
|
||||
<div className="mail-account">
|
||||
<div className="mail-account-email">{email}</div>
|
||||
<button className="btn btn-ghost btn-sm" onClick={handleDisconnect} style={{ fontSize: 11 }}>
|
||||
Çıkış
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mail-list">
|
||||
<div className="mail-list-header">
|
||||
<h2>
|
||||
{folders.find((f) => f.path === activeFolder)?.name ?? activeFolder}
|
||||
</h2>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => loadMessages(activeFolder)}>↻</button>
|
||||
</div>
|
||||
<MessageList
|
||||
messages={messages}
|
||||
loading={loading}
|
||||
selectedUid={selectedUid}
|
||||
onSelect={openMsg}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mail-detail">
|
||||
{openMessage ? (
|
||||
<MessageView
|
||||
message={openMessage}
|
||||
onReply={() => handleReply(openMessage)}
|
||||
onDelete={() => handleDelete(openMessage.uid)}
|
||||
folder={activeFolder}
|
||||
/>
|
||||
) : (
|
||||
<div className="mail-empty">
|
||||
<div className="mail-empty-icon">
|
||||
<MailBigIcon />
|
||||
</div>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, color: "var(--text-secondary)" }}>Bir mail seçin</div>
|
||||
<div style={{ fontSize: 12 }}>Okumak için soldaki listeden bir mail seçin</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCompose && (
|
||||
<ComposeModal
|
||||
replyTo={replyTo}
|
||||
onClose={() => { setShowCompose(false); setReplyTo(null); }}
|
||||
onSent={() => { setShowCompose(false); setReplyTo(null); loadMessages(activeFolder); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MailBigIcon() {
|
||||
return <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.5 }}><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>;
|
||||
}
|
||||
|
||||
function ComposeIcon() {
|
||||
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>;
|
||||
}
|
||||
382
app/dashboard/mailboxes/page.tsx
Normal file
382
app/dashboard/mailboxes/page.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useTransition, useCallback } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { formatBytes } from "@/lib/format";
|
||||
|
||||
interface Mailbox {
|
||||
username: string;
|
||||
name: string;
|
||||
local_part: string;
|
||||
domain: string;
|
||||
quota: number;
|
||||
quota_used: number;
|
||||
active: string; // "1" | "0"
|
||||
}
|
||||
|
||||
interface Domain {
|
||||
domain_name: string;
|
||||
}
|
||||
|
||||
export default function MailboxesPage() {
|
||||
const { data: session } = useSession();
|
||||
const [domains, setDomains] = useState<Domain[]>([]);
|
||||
const [selectedDomain, setSelectedDomain] = useState("");
|
||||
const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showPasswordModal, setShowPasswordModal] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [search, setSearch] = useState("");
|
||||
const [createForm, setCreateForm] = useState({ local_part: "", name: "", password: "", quota: 3072 });
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [formError, setFormError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/domains")
|
||||
.then((r) => r.json())
|
||||
.then((data: Domain[]) => {
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
setDomains(data);
|
||||
setSelectedDomain(data[0].domain_name);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchMailboxes = useCallback(async (domain: string) => {
|
||||
if (!domain) return;
|
||||
setLoading(true);
|
||||
const res = await fetch(`/api/mailboxes?domain=${encodeURIComponent(domain)}`);
|
||||
const data = await res.json();
|
||||
setMailboxes(Array.isArray(data) ? data : []);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDomain) fetchMailboxes(selectedDomain);
|
||||
}, [selectedDomain, fetchMailboxes]);
|
||||
|
||||
const handleCreate = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError("");
|
||||
startTransition(async () => {
|
||||
const res = await fetch("/api/mailboxes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...createForm, domain: selectedDomain }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setShowCreateModal(false);
|
||||
setCreateForm({ local_part: "", name: "", password: "", quota: 3072 });
|
||||
fetchMailboxes(selectedDomain);
|
||||
} else {
|
||||
const msg = Array.isArray(data)
|
||||
? data.map((d: { msg?: unknown }) => JSON.stringify(d.msg)).join(", ")
|
||||
: (data?.error ?? "Mailcow bağlantısını kontrol edin");
|
||||
setFormError(String(msg));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (username: string) => {
|
||||
if (!confirm(`"${username}" hesabını silmek istediğinizden emin misiniz?`)) return;
|
||||
startTransition(async () => {
|
||||
await fetch(`/api/mailboxes/${encodeURIComponent(username)}`, { method: "DELETE" });
|
||||
setMailboxes((prev) => prev.filter((m) => m.username !== username));
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggle = (username: string, active: string) => {
|
||||
const newActive = String(active) === "1" ? 0 : 1;
|
||||
startTransition(async () => {
|
||||
await fetch(`/api/mailboxes/${encodeURIComponent(username)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ active: newActive }),
|
||||
});
|
||||
setMailboxes((prev) =>
|
||||
prev.map((m) => m.username === username ? { ...m, active: String(newActive) } : m)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const handlePasswordChange = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!showPasswordModal) return;
|
||||
startTransition(async () => {
|
||||
await fetch(`/api/mailboxes/${encodeURIComponent(showPasswordModal)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password: newPassword }),
|
||||
});
|
||||
setShowPasswordModal(null);
|
||||
setNewPassword("");
|
||||
});
|
||||
};
|
||||
|
||||
const filtered = mailboxes.filter(
|
||||
(m) =>
|
||||
m.username.toLowerCase().includes(search.toLowerCase()) ||
|
||||
m.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const isSuperAdmin = session?.user?.role === "SUPER_ADMIN";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Mail Hesapları</h1>
|
||||
<p className="page-subtitle">
|
||||
{selectedDomain
|
||||
? `${selectedDomain} — ${mailboxes.length} hesap`
|
||||
: "Domain seçin"}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
|
||||
<select
|
||||
className="input"
|
||||
style={{ width: "auto", minWidth: 200 }}
|
||||
value={selectedDomain}
|
||||
onChange={(e) => setSelectedDomain(e.target.value)}
|
||||
>
|
||||
{domains.map((d) => (
|
||||
<option key={d.domain_name} value={d.domain_name}>
|
||||
{d.domain_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => fetchMailboxes(selectedDomain)}
|
||||
disabled={!selectedDomain}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
disabled={!selectedDomain}
|
||||
>
|
||||
<PlusIcon /> Hesap Ekle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-body">
|
||||
<div className="search-bar">
|
||||
<div className="search-input-wrap">
|
||||
<span className="search-icon"><SearchIcon /></span>
|
||||
<input
|
||||
type="text"
|
||||
className="input search-input"
|
||||
placeholder="E-posta veya isim ara..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-wrap">
|
||||
{loading ? (
|
||||
<div className="empty-state">
|
||||
<span className="spinner" style={{ width: 24, height: 24 }} />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon"><MailIcon size={24} /></div>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
{selectedDomain ? "Bu domainde mail hesabı yok" : "Domain seçin"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
||||
{selectedDomain ? '"Hesap Ekle" butonuna tıklayın' : "Sol üstteki listeden domain seçin"}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>E-posta</th>
|
||||
<th>Ad Soyad</th>
|
||||
<th>Kota</th>
|
||||
<th>Durum</th>
|
||||
<th>İşlemler</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((m) => {
|
||||
const usedPct = m.quota > 0 ? Math.min((m.quota_used / m.quota) * 100, 100) : 0;
|
||||
return (
|
||||
<tr key={m.username}>
|
||||
<td>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div style={{
|
||||
width: 30, height: 30, borderRadius: 6,
|
||||
background: "var(--accent-dim)", color: "var(--accent-hover)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
flexShrink: 0, fontSize: 13, fontWeight: 700,
|
||||
}}>
|
||||
{m.local_part[0]?.toUpperCase()}
|
||||
</div>
|
||||
<span style={{ fontWeight: 500 }}>{m.username}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ color: "var(--text-secondary)" }}>{m.name}</td>
|
||||
<td style={{ minWidth: 160 }}>
|
||||
<div style={{ fontSize: 11, color: "var(--text-secondary)", marginBottom: 4 }}>
|
||||
{formatBytes(m.quota_used)} / {formatBytes(m.quota)}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<div className="progress-bar">
|
||||
<div className={`progress-fill ${usedPct > 80 ? "danger" : ""}`} style={{ width: `${usedPct}%` }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: "var(--text-muted)", flexShrink: 0 }}>{Math.round(usedPct)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${String(m.active) === "1" ? "badge-green" : "badge-red"}`}>
|
||||
{String(m.active) === "1" ? "● Aktif" : "● Pasif"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setShowPasswordModal(m.username)}
|
||||
title="Şifre Değiştir"
|
||||
>
|
||||
<KeyIcon />
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm ${String(m.active) === "1" ? "btn-ghost" : "btn-success"}`}
|
||||
onClick={() => handleToggle(m.username, m.active)}
|
||||
title={String(m.active) === "1" ? "Pasife Al" : "Aktif Et"}
|
||||
>
|
||||
{String(m.active) === "1" ? <PauseIcon /> : <PlayIcon />}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => handleDelete(m.username)}
|
||||
title="Sil"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && setShowCreateModal(false)}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">Yeni Mail Hesabı</h2>
|
||||
<button className="modal-close" onClick={() => setShowCreateModal(false)}><XIcon /></button>
|
||||
</div>
|
||||
<form onSubmit={handleCreate}>
|
||||
<div className="modal-body form-group">
|
||||
{formError && <div className="error-msg">{formError}</div>}
|
||||
<div>
|
||||
<label className="label">Kullanıcı Adı</label>
|
||||
<div style={{ display: "flex" }}>
|
||||
<input
|
||||
type="text" className="input"
|
||||
style={{ borderRadius: "var(--radius) 0 0 var(--radius)" }}
|
||||
placeholder="info"
|
||||
value={createForm.local_part}
|
||||
onChange={(e) => setCreateForm({ ...createForm, local_part: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<span style={{
|
||||
background: "var(--bg-hover)", border: "1px solid var(--border)", borderLeft: "none",
|
||||
padding: "8px 12px", borderRadius: "0 var(--radius) var(--radius) 0",
|
||||
color: "var(--text-secondary)", fontSize: 13, whiteSpace: "nowrap",
|
||||
}}>
|
||||
@{selectedDomain}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Ad Soyad</label>
|
||||
<input type="text" className="input" placeholder="Emina Karabudak"
|
||||
value={createForm.name}
|
||||
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
|
||||
required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Şifre</label>
|
||||
<input type="password" className="input" placeholder="Güçlü bir şifre"
|
||||
value={createForm.password}
|
||||
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
|
||||
required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Kota (MB)</label>
|
||||
<input type="number" className="input" value={createForm.quota} min={100} max={102400}
|
||||
onChange={(e) => setCreateForm({ ...createForm, quota: parseInt(e.target.value) || 3072 })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setShowCreateModal(false)}>İptal</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={isPending}>
|
||||
{isPending ? <span className="spinner" /> : <PlusIcon />} Oluştur
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password Modal */}
|
||||
{showPasswordModal && (
|
||||
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && setShowPasswordModal(null)}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">Şifre Değiştir</h2>
|
||||
<button className="modal-close" onClick={() => setShowPasswordModal(null)}><XIcon /></button>
|
||||
</div>
|
||||
<form onSubmit={handlePasswordChange}>
|
||||
<div className="modal-body form-group">
|
||||
<div style={{ fontSize: 13, color: "var(--text-secondary)" }}>
|
||||
<strong style={{ color: "var(--text-primary)" }}>{showPasswordModal}</strong> için yeni şifre
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Yeni Şifre</label>
|
||||
<input type="password" className="input" placeholder="Yeni şifre"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required autoFocus />
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setShowPasswordModal(null)}>İptal</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={isPending}>
|
||||
{isPending ? <span className="spinner" /> : <KeyIcon />} Güncelle
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Icons
|
||||
function PlusIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>; }
|
||||
function SearchIcon() { return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>; }
|
||||
function RefreshIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/></svg>; }
|
||||
function MailIcon({ size = 13 }: { size?: number }) { return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>; }
|
||||
function TrashIcon() { return <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>; }
|
||||
function KeyIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/></svg>; }
|
||||
function PauseIcon() { return <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>; }
|
||||
function PlayIcon() { return <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>; }
|
||||
function XIcon() { return <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>; }
|
||||
211
app/dashboard/page.tsx
Normal file
211
app/dashboard/page.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { auth } from "@/auth";
|
||||
import { getDomains } from "@/lib/mailcow";
|
||||
import { canAccessDomain } from "@/lib/users";
|
||||
import { formatBytes } from "@/lib/format";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await auth();
|
||||
const role = session?.user?.role;
|
||||
const userDomains = session?.user?.domains ?? [];
|
||||
|
||||
const allDomains = await getDomains();
|
||||
const visibleDomains = allDomains.filter((d) => canAccessDomain(userDomains, d.domain_name));
|
||||
|
||||
const totalMailboxes = visibleDomains.reduce((sum, d) => sum + d.mboxes_in_domain, 0);
|
||||
const totalAliases = visibleDomains.reduce((sum, d) => sum + d.aliases_in_domain, 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Dashboard</h1>
|
||||
<p className="page-subtitle">Hoş geldiniz, {session?.user?.name} 👋</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-body">
|
||||
<div className="stats-grid">
|
||||
<StatCard
|
||||
label="Toplam Domain"
|
||||
value={visibleDomains.length}
|
||||
color="var(--accent)"
|
||||
icon={<GlobeIcon />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Mail Kutuları"
|
||||
value={totalMailboxes}
|
||||
color="var(--success)"
|
||||
icon={<MailIcon />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Alias"
|
||||
value={totalAliases}
|
||||
color="var(--warning)"
|
||||
icon={<AtIcon />}
|
||||
/>
|
||||
{role === "SUPER_ADMIN" && (
|
||||
<StatCard
|
||||
label="Tanımlı Kullanıcı"
|
||||
value={"—"}
|
||||
sub="Kullanıcılar .env'den yönetilir"
|
||||
color="var(--text-muted)"
|
||||
icon={<UsersIcon />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Domain durum tablosu */}
|
||||
{visibleDomains.length > 0 && (
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div style={{ padding: "16px 20px", borderBottom: "1px solid var(--border)" }}>
|
||||
<h2 style={{ fontSize: 14, fontWeight: 700, color: "var(--text-primary)" }}>
|
||||
Domain Durumu
|
||||
</h2>
|
||||
</div>
|
||||
<div className="table-wrap" style={{ border: "none", borderRadius: 0 }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<th>Mail Kutuları</th>
|
||||
<th>Kota Kullanımı</th>
|
||||
<th>Durum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleDomains.map((d) => {
|
||||
const quotaUsed = Number(d.quota_used_in_domain);
|
||||
const quotaTotal = d.max_quota_for_domain;
|
||||
const pct = quotaTotal > 0 ? Math.min((quotaUsed / quotaTotal) * 100, 100) : 0;
|
||||
return (
|
||||
<tr key={d.domain_name}>
|
||||
<td>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: 6, background: "var(--accent-dim)", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--accent-hover)", flexShrink: 0 }}>
|
||||
<GlobeIcon />
|
||||
</div>
|
||||
<span style={{ fontWeight: 500 }}>{d.domain_name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span>{d.mboxes_in_domain}</span>
|
||||
<span style={{ color: "var(--text-muted)", fontSize: 12 }}> / {d.max_num_mboxes_for_domain}</span>
|
||||
</td>
|
||||
<td style={{ minWidth: 160 }}>
|
||||
<div style={{ fontSize: 11, color: "var(--text-secondary)", marginBottom: 4 }}>
|
||||
{formatBytes(quotaUsed)} / {formatBytes(quotaTotal)}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div className="progress-bar">
|
||||
<div className={`progress-fill ${pct > 80 ? "danger" : ""}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: "var(--text-muted)", flexShrink: 0 }}>{Math.round(pct)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${String(d.active) === "1" ? "badge-green" : "badge-red"}`}>
|
||||
{String(d.active) === "1" ? "● Aktif" : "● Pasif"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="card">
|
||||
<h2 style={{ fontSize: 14, fontWeight: 700, marginBottom: 16, color: "var(--text-primary)" }}>
|
||||
Hızlı İşlemler
|
||||
</h2>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{role === "SUPER_ADMIN" && (
|
||||
<QuickItem
|
||||
href="/dashboard/domains"
|
||||
icon={<GlobeIcon />}
|
||||
title="Domain Yönetimi"
|
||||
desc="Domain ekle, sil, yönet"
|
||||
color="var(--accent)"
|
||||
/>
|
||||
)}
|
||||
<QuickItem
|
||||
href="/dashboard/mailboxes"
|
||||
icon={<MailIcon />}
|
||||
title="Mail Hesapları"
|
||||
desc="Yeni hesap oluştur, şifre değiştir, sil"
|
||||
color="var(--success)"
|
||||
/>
|
||||
{role === "SUPER_ADMIN" && (
|
||||
<QuickItem
|
||||
href="/dashboard/users"
|
||||
icon={<UsersIcon />}
|
||||
title="Kullanıcılar"
|
||||
desc=".env'den tanımlı panel kullanıcılarını görüntüle"
|
||||
color="var(--warning)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, sub, color, icon }: {
|
||||
label: string;
|
||||
value: number | string;
|
||||
sub?: string;
|
||||
color: string;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon" style={{ background: `${color}20`, color }}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="stat-label">{label}</div>
|
||||
<div className="stat-value">{value}</div>
|
||||
{sub && <div style={{ fontSize: 11, color: "var(--text-muted)" }}>{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickItem({ href, icon, title, desc, color }: {
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
desc: string;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 14, padding: 14,
|
||||
borderRadius: "var(--radius)", border: "1px solid var(--border)",
|
||||
background: "var(--bg)", textDecoration: "none",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 8, background: `${color}20`, color, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
|
||||
{icon}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--text-primary)" }}>{title}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-secondary)", marginTop: 2 }}>{desc}</div>
|
||||
</div>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// Icons
|
||||
function GlobeIcon() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/><path d="M2 12h20"/></svg>; }
|
||||
function MailIcon() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>; }
|
||||
function AtIcon() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94"/></svg>; }
|
||||
function UsersIcon() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>; }
|
||||
142
app/dashboard/users/page.tsx
Normal file
142
app/dashboard/users/page.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
domains: string[];
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/users")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setUsers(Array.isArray(data) ? data : []);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const filtered = users.filter(
|
||||
(u) =>
|
||||
u.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Kullanıcılar</h1>
|
||||
<p className="page-subtitle">Panel kullanıcıları .env dosyasından yönetilir</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-body">
|
||||
{/* Info card */}
|
||||
<div className="card" style={{ display: "flex", gap: 14, alignItems: "flex-start", border: "1px solid var(--accent-dim)", background: "var(--accent-dim)" }}>
|
||||
<div style={{ color: "var(--accent-hover)", flexShrink: 0, paddingTop: 2 }}>
|
||||
<InfoIcon />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, color: "var(--accent-hover)", marginBottom: 6 }}>Kullanıcı yönetimi hakkında</div>
|
||||
<div style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.7 }}>
|
||||
Kullanıcılar <code style={{ background: "var(--bg)", padding: "1px 6px", borderRadius: 4, fontSize: 12 }}>.env</code> dosyasındaki{" "}
|
||||
<code style={{ background: "var(--bg)", padding: "1px 6px", borderRadius: 4, fontSize: 12 }}>USER_0_*</code>,{" "}
|
||||
<code style={{ background: "var(--bg)", padding: "1px 6px", borderRadius: 4, fontSize: 12 }}>USER_1_*</code>… değişkenleriyle tanımlanır.
|
||||
Yeni kullanıcı eklemek için .env dosyasını düzenleyip uygulamayı yeniden başlatın.
|
||||
</div>
|
||||
<div style={{ marginTop: 12, padding: "10px 14px", background: "var(--bg)", borderRadius: "var(--radius)", fontSize: 12, fontFamily: "monospace", color: "var(--text-secondary)", lineHeight: 2 }}>
|
||||
USER_2_NAME="Ahmet Yılmaz"<br />
|
||||
USER_2_EMAIL="ahmet@ayristech.com"<br />
|
||||
USER_2_PASSWORD="güçlü-şifre"<br />
|
||||
USER_2_ROLE="DOMAIN_ADMIN"<br />
|
||||
USER_2_DOMAINS="yenidomain.com"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="search-bar">
|
||||
<div className="search-input-wrap">
|
||||
<span className="search-icon"><SearchIcon /></span>
|
||||
<input
|
||||
type="text"
|
||||
className="input search-input"
|
||||
placeholder="İsim veya e-posta ara..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-wrap">
|
||||
{loading ? (
|
||||
<div className="empty-state">
|
||||
<span className="spinner" style={{ width: 24, height: 24 }} />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon"><UsersIcon /></div>
|
||||
<div style={{ fontWeight: 600 }}>Kullanıcı bulunamadı</div>
|
||||
</div>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kullanıcı</th>
|
||||
<th>Rol</th>
|
||||
<th>İzin Verilen Domainler</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<div className="user-avatar" style={{ width: 32, height: 32, fontSize: 13 }}>
|
||||
{u.name[0]?.toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{u.name}</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>{u.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${u.role === "SUPER_ADMIN" ? "badge-blue" : "badge-green"}`}>
|
||||
{u.role === "SUPER_ADMIN" ? "★ Süper Admin" : "Domain Admin"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{u.domains.includes("*") ? (
|
||||
<span className="badge badge-blue">Tüm domainler</span>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
|
||||
{u.domains.map((d) => (
|
||||
<span key={d} className="badge badge-green">{d}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchIcon() { return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>; }
|
||||
function UsersIcon() { return <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>; }
|
||||
function InfoIcon() { return <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>; }
|
||||
1113
app/globals.css
1113
app/globals.css
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "AyrisMail Central",
|
||||
description: "Multi-tenant Mailcow yönetim paneli — AyrisTech",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -23,11 +12,8 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<html lang="tr">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
90
app/login/page.tsx
Normal file
90
app/login/page.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
startTransition(async () => {
|
||||
const res = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (res?.error) {
|
||||
setError("E-posta veya şifre hatalı.");
|
||||
} else {
|
||||
router.push("/dashboard");
|
||||
router.refresh();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-box">
|
||||
<div className="login-header">
|
||||
<div className="login-logo">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect width="20" height="16" x="2" y="4" rx="2" />
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="login-title">AyrisMail Central</h1>
|
||||
<p className="login-sub">Mail sunucunuzu kolayca yönetin</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<form onSubmit={handleSubmit} className="form-group">
|
||||
{error && <div className="error-msg">{error}</div>}
|
||||
<div>
|
||||
<label htmlFor="email" className="label">E-posta</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
className="input"
|
||||
placeholder="admin@ayristech.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="label">Şifre</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
className="input"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" disabled={isPending} style={{ width: "100%", justifyContent: "center", padding: "10px" }}>
|
||||
{isPending ? <span className="spinner" /> : null}
|
||||
{isPending ? "Giriş yapılıyor..." : "Giriş Yap"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p style={{ textAlign: "center", marginTop: "20px", fontSize: "12px", color: "var(--text-muted)" }}>
|
||||
AyrisTech © {new Date().getFullYear()} — Tüm hakları saklıdır.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
app/page.tsx
72
app/page.tsx
@@ -1,65 +1,11 @@
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
export default async function HomePage() {
|
||||
const session = await auth();
|
||||
if (session) {
|
||||
redirect("/dashboard");
|
||||
} else {
|
||||
redirect("/login");
|
||||
}
|
||||
}
|
||||
|
||||
53
auth.ts
Normal file
53
auth.ts
Normal file
@@ -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",
|
||||
},
|
||||
});
|
||||
7
components/Providers.tsx
Normal file
7
components/Providers.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
||||
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
148
components/Sidebar.tsx
Normal file
148
components/Sidebar.tsx
Normal file
@@ -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 (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-logo">
|
||||
<div className="sidebar-logo-icon">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect width="20" height="16" x="2" y="4" rx="2" />
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="sidebar-logo-text">AyrisMail</div>
|
||||
<div className="sidebar-logo-sub">Central</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
{navItems.map((group) => {
|
||||
const visibleItems = group.items.filter((item) => item.roles.includes(role));
|
||||
if (visibleItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={group.section}>
|
||||
<div className="sidebar-section">{group.section}</div>
|
||||
{visibleItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`nav-item ${pathname === item.href ? "active" : ""}`}
|
||||
>
|
||||
<item.icon />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<div className="user-info" style={{ marginBottom: "12px" }}>
|
||||
<div className="user-avatar">{name[0]?.toUpperCase()}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="user-name" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{name}</div>
|
||||
<div className="user-role">{role === "SUPER_ADMIN" ? "Süper Admin" : "Domain Admin"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ width: "100%", justifyContent: "center", fontSize: "12px" }}
|
||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||
>
|
||||
<LogOutIcon />
|
||||
Çıkış Yap
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// Icons
|
||||
function HomeIcon() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GlobeIcon() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
<path d="M2 12h20" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersIcon() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MailIcon() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect width="20" height="16" x="2" y="4" rx="2" />
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function InboxIcon() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
|
||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LogOutIcon() {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" x2="9" y1="12" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
165
components/mail/ComposeModal.tsx
Normal file
165
components/mail/ComposeModal.tsx
Normal file
@@ -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<AttachmentFile[]>([]);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(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", `<pre style="font-family:inherit;white-space:pre-wrap">${body.replace(/</g, "<")}</pre>`);
|
||||
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: `<pre style="font-family:inherit;white-space:pre-wrap">${body.replace(/</g, "<")}</pre>`,
|
||||
}),
|
||||
});
|
||||
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 (
|
||||
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal" style={{ maxWidth: 620 }}>
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">{replyTo ? "Yanıtla" : "Yeni Mail"}</h2>
|
||||
<button className="modal-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
<form onSubmit={handleSend}>
|
||||
<div className="modal-body form-group">
|
||||
{error && <div className="error-msg">{error}</div>}
|
||||
<div>
|
||||
<label className="label">Alıcı</label>
|
||||
<input type="email" className="input" placeholder="alici@domain.com" value={to}
|
||||
onChange={(e) => setTo(e.target.value)} required autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">CC (isteğe bağlı)</label>
|
||||
<input type="text" className="input" placeholder="cc@domain.com" value={cc}
|
||||
onChange={(e) => setCc(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Konu</label>
|
||||
<input type="text" className="input" value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Mesaj</label>
|
||||
<textarea className="input" rows={8} value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
style={{ resize: "vertical", fontFamily: "inherit" }} />
|
||||
</div>
|
||||
|
||||
{/* Attachment zone */}
|
||||
<div
|
||||
className={`compose-dropzone ${dragOver ? "active" : ""}`}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => { addFiles(e.target.files); e.target.value = ""; }}
|
||||
/>
|
||||
<span style={{ fontSize: 20 }}>📎</span>
|
||||
<span>Dosya sürükleyin veya tıklayın</span>
|
||||
</div>
|
||||
|
||||
{/* Attachment list */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="compose-attachments">
|
||||
{attachments.map((att, i) => (
|
||||
<div key={i} className="compose-att-item">
|
||||
<span>📎 {att.name}</span>
|
||||
<span style={{ color: "var(--text-muted)", fontSize: 11 }}>{formatBytes(att.size)}</span>
|
||||
<button type="button" className="att-remove" onClick={() => removeAttachment(i)}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 4 }}>
|
||||
Toplam: {formatBytes(totalSize)} — {attachments.length} dosya
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose}>İptal</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={sending}>
|
||||
{sending ? <span className="spinner" /> : <SendIcon />} Gönder
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SendIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="22" x2="11" y1="2" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>; }
|
||||
66
components/mail/FolderList.tsx
Normal file
66
components/mail/FolderList.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
import type { MailFolder } from "@/app/dashboard/mail/page";
|
||||
|
||||
const FOLDER_ICONS: Record<string, string> = {
|
||||
"\\Inbox": "📥",
|
||||
"\\Sent": "📤",
|
||||
"\\Drafts": "📝",
|
||||
"\\Trash": "🗑️",
|
||||
"\\Junk": "⚠️",
|
||||
"\\Archive": "📦",
|
||||
};
|
||||
|
||||
const FOLDER_LABELS: Record<string, string> = {
|
||||
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 (
|
||||
<div className="folder-list">
|
||||
{sorted.map((f) => (
|
||||
<button
|
||||
key={f.path}
|
||||
className={`folder-item ${active === f.path ? "active" : ""}`}
|
||||
onClick={() => onSelect(f.path)}
|
||||
>
|
||||
<span className="folder-icon">{getFolderIcon(f)}</span>
|
||||
<span className="folder-name">{getFolderLabel(f)}</span>
|
||||
{f.unseen > 0 && <span className="folder-badge">{f.unseen}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
components/mail/MailLogin.tsx
Normal file
60
components/mail/MailLogin.tsx
Normal file
@@ -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 (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", minHeight: "60vh" }}>
|
||||
<div className="card" style={{ maxWidth: 420, width: "100%" }}>
|
||||
<div style={{ textAlign: "center", marginBottom: 24 }}>
|
||||
<div style={{ fontSize: 32, marginBottom: 8 }}>📧</div>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700, color: "var(--text-primary)" }}>Mail Hesabına Bağlan</h2>
|
||||
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginTop: 4 }}>
|
||||
Mailcow mail hesabınızın bilgilerini girin
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="form-group">
|
||||
{error && <div className="error-msg">{error}</div>}
|
||||
<div>
|
||||
<label className="label">E-posta Adresi</label>
|
||||
<input type="email" className="input" placeholder="info@domain.com" value={email}
|
||||
onChange={(e) => setEmail(e.target.value)} required autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Şifre</label>
|
||||
<input type="password" className="input" placeholder="Mail hesabı şifresi" value={password}
|
||||
onChange={(e) => setPassword(e.target.value)} required />
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" style={{ width: "100%" }} disabled={loading}>
|
||||
{loading ? <span className="spinner" /> : "Bağlan"}
|
||||
</button>
|
||||
<p style={{ fontSize: 11, color: "var(--text-muted)", textAlign: "center", marginTop: 8 }}>
|
||||
Şifreniz sunucuda saklanmaz, sadece oturum süresince kullanılır.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
components/mail/MessageList.tsx
Normal file
65
components/mail/MessageList.tsx
Normal file
@@ -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 <div className="empty-state" style={{ padding: 40 }}><span className="spinner" style={{ width: 20, height: 20 }} /></div>;
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="empty-state" style={{ padding: 40 }}>
|
||||
<div style={{ fontSize: 13, color: "var(--text-muted)" }}>Bu klasörde mail yok</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="message-list-inner">
|
||||
{messages.map((m) => (
|
||||
<div
|
||||
key={m.uid}
|
||||
className={`message-row ${selectedUid === m.uid ? "selected" : ""} ${!m.seen ? "unread" : ""}`}
|
||||
onClick={() => onSelect(m.uid)}
|
||||
>
|
||||
<div className="message-avatar">
|
||||
{senderName(m)[0]?.toUpperCase() ?? "?"}
|
||||
</div>
|
||||
<div className="message-content">
|
||||
<div className="message-top">
|
||||
<span className="message-sender">{senderName(m)}</span>
|
||||
<span className="message-time">{timeAgo(m.date)}</span>
|
||||
</div>
|
||||
<div className="message-subject">{m.subject}</div>
|
||||
</div>
|
||||
{m.hasAttachments && <span className="message-attach" title="Ek var">📎</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
components/mail/MessageView.tsx
Normal file
147
components/mail/MessageView.tsx
Normal file
@@ -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 (
|
||||
<div className="message-view">
|
||||
{/* Header */}
|
||||
<div className="message-view-header">
|
||||
<h2 className="message-view-subject">{message.subject}</h2>
|
||||
<div className="message-view-actions">
|
||||
<button className="btn btn-ghost btn-sm" onClick={onReply} title="Yanıtla">
|
||||
<ReplyIcon /> Yanıtla
|
||||
</button>
|
||||
<button className="btn btn-danger btn-sm" onClick={onDelete} title="Sil">
|
||||
<TrashIcon /> Sil
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="message-view-meta">
|
||||
<div className="message-view-avatar">{(from?.name || from?.address)?.[0]?.toUpperCase() ?? "?"}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>{from?.name || from?.address}</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-muted)" }}>
|
||||
{from?.address}
|
||||
{message.to.length > 0 && <> → {message.to.map((t) => t.address).join(", ")}</>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-muted)", flexShrink: 0 }}>{date}</div>
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
{message.attachments.length > 0 && (
|
||||
<div className="message-attachments">
|
||||
<div style={{ width: "100%", fontSize: 12, fontWeight: 600, color: "var(--text-secondary)", marginBottom: 6 }}>
|
||||
📎 {message.attachments.length} ek
|
||||
</div>
|
||||
{message.attachments.map((att, i) => (
|
||||
<div key={i} className="attachment-chip" onClick={() => handleAttachment(att, false)}>
|
||||
<span>{getFileIcon(att.contentType, att.filename)}</span>
|
||||
<span style={{ maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{att.filename}
|
||||
</span>
|
||||
<span style={{ color: "var(--text-muted)", fontSize: 11 }}>{formatBytes(att.size)}</span>
|
||||
<span className="attachment-actions">
|
||||
<button
|
||||
className="att-btn"
|
||||
onClick={(e) => { e.stopPropagation(); handleAttachment(att, false); }}
|
||||
title="İndir"
|
||||
>
|
||||
⬇
|
||||
</button>
|
||||
{canPreview(att.contentType) && (
|
||||
<button
|
||||
className="att-btn"
|
||||
onClick={(e) => { e.stopPropagation(); handleAttachment(att, true); }}
|
||||
title="Önizle"
|
||||
>
|
||||
👁
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
<div className="message-view-body">
|
||||
{message.html ? (
|
||||
<iframe
|
||||
srcDoc={sanitizeHtml(message.html)}
|
||||
sandbox="allow-same-origin"
|
||||
style={{ width: "100%", border: "none", minHeight: 400 }}
|
||||
onLoad={(e) => {
|
||||
const iframe = e.target as HTMLIFrameElement;
|
||||
if (iframe.contentDocument) {
|
||||
iframe.style.height = iframe.contentDocument.body.scrollHeight + 20 + "px";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<pre style={{ whiteSpace: "pre-wrap", fontFamily: "inherit", fontSize: 13, color: "var(--text-secondary)" }}>
|
||||
{message.text}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
const style = `<style>
|
||||
body { font-family: Inter, -apple-system, sans-serif; font-size: 14px; color: #c9d1d9; background: transparent; margin: 0; padding: 8px; line-height: 1.6; }
|
||||
a { color: #58a6ff; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
</style>`;
|
||||
let clean = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "");
|
||||
clean = clean.replace(/\son\w+\s*=\s*["'][^"']*["']/gi, "");
|
||||
return `<!DOCTYPE html><html><head>${style}</head><body>${clean}</body></html>`;
|
||||
}
|
||||
|
||||
function ReplyIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>; }
|
||||
function TrashIcon() { return <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>; }
|
||||
6180
docs/openapi.yaml
Normal file
6180
docs/openapi.yaml
Normal file
File diff suppressed because it is too large
Load Diff
62
docs/prd.md
Normal file
62
docs/prd.md
Normal file
@@ -0,0 +1,62 @@
|
||||
🚀 Ürün Gereksinim Dokümanı (PRD): AyrisMail Central
|
||||
1. Ürün Özeti
|
||||
AyrisMail Central, Mailcow API’sini kullanarak birden fazla domaini ve bu domainlere bağlı mail hesaplarını yönetmeye yarayan, Next.js tabanlı bir "Multi-Tenant" (Çoklu Kiracılı) yönetim panelidir. Kullanıcılar, teknik bilgiye ihtiyaç duymadan kendi domainleri altında mail hesabı açıp kapatabilirler.
|
||||
|
||||
2. Kullanıcı Rolleri ve Yetkiler
|
||||
Süper Admin (Mustafa): * Tüm domainleri ekler/siler.
|
||||
|
||||
Domain Admin’leri oluşturur.
|
||||
|
||||
Sunucu genel kotasını ve API anahtarlarını yönetir.
|
||||
|
||||
Domain Admin (Örn: Emina Hanım):
|
||||
|
||||
Sadece kendisine atanmış olan domaini (örn: aveminakarabudak.com) görür.
|
||||
|
||||
Yeni mail kutusu oluşturabilir, mevcut olanları silebilir.
|
||||
|
||||
Kullanıcıların şifrelerini sıfırlayabilir ve kotalarını güncelleyebilir.
|
||||
|
||||
3. Temel Özellikler (MVP)
|
||||
A. Dashboard & Auth
|
||||
|
||||
Güvenli Giriş: NextAuth.js ile e-posta ve şifre tabanlı giriş sistemi.
|
||||
|
||||
Hızlı Özet: Aktif mail kutusu sayısı, kullanılan toplam disk alanı ve domain sağlık durumu (SPF/DKIM/MX) göstergeleri.
|
||||
|
||||
B. Domain Yönetimi (Süper Admin)
|
||||
|
||||
Domain Listesi: Tüm kayıtlı domainlerin tablosu.
|
||||
|
||||
Domain Atama: Hangi domainin hangi kullanıcı tarafından yönetileceğinin belirlenmesi.
|
||||
|
||||
C. Mail Hesabı Yönetimi (Domain Admin)
|
||||
|
||||
Hesap Oluşturma Formu: * local_part (isim), name (Ad Soyad), password ve quota (MB) girişleri.
|
||||
|
||||
Hesap Listeleme: Mevcut mail hesaplarının (örn: info@domain.com) tablo halinde gösterimi.
|
||||
|
||||
Hızlı İşlemler: Tek tıkla şifre güncelleme veya hesabı pasife alma.
|
||||
|
||||
4. Teknik Altyapı (Tech Stack)
|
||||
Framework: Next.js 14+ (App Router)
|
||||
|
||||
Stil: Tailwind CSS + Shadcn/UI (Karanlık Mod desteği dahil)
|
||||
|
||||
State Management: TanStack Query (API verilerini anlık çekmek için)
|
||||
|
||||
API Entegrasyonu: Mailcow API (Server-side fetch işlemleri)
|
||||
|
||||
Veritabanı (Küçük bir DB): Kullanıcı rolleri ve domain atamalarını tutmak için (Supabase veya Prisma/Postgres).
|
||||
|
||||
5. UI/UX Tasarım Konsepti
|
||||
Temiz Arayüz: Mailcow’un binlerce ayarı yerine sadece "Mail Ekle", "Şifre Değiştir", "Sil" butonlarının olduğu minimalist bir tasarım.
|
||||
|
||||
White-Label: Giriş ekranında AyrisTech logosu, içeride ise ilgili domainin başlığı.
|
||||
|
||||
Mobil Uyum: Telefon üzerinden kolayca yeni mail açabilme yeteneği.
|
||||
|
||||
6. Başarı Kriterleri
|
||||
Bir domain admininin sisteme girip yeni bir mail hesabı oluşturma süresinin 15 saniyenin altında olması.
|
||||
|
||||
Süper adminin sunucu terminaline veya ana Mailcow paneline girmeden tüm günlük işlerini Next.js üzerinden yapabilmesi.
|
||||
49
docs/prd2.md
Normal file
49
docs/prd2.md
Normal file
@@ -0,0 +1,49 @@
|
||||
📧 Ürün Gereksinim Dokümanı (PRD): AyrisMail Web Client Modülü
|
||||
1. Ürün Özeti
|
||||
AyrisMail Web Client, AyrisMail Central ekosisteminin içine entegre edilmiş, modern, hızlı ve minimalist bir e-posta okuma/yazma modülüdür. Kullanıcıların Mailcow sunucusu üzerindeki e-postalarına IMAP/SMTP protokolleri üzerinden tarayıcı tabanlı erişimini sağlar.
|
||||
|
||||
2. Kullanıcı Deneyimi (UX) Hedefleri
|
||||
Gmail Tarzı Akış: Hızlı geçişler, klavye kısayolları ve temiz bir okuma alanı.
|
||||
|
||||
Zero-Refresh: Next.js ve TanStack Query kullanarak sayfa yenilenmeden mail okuma ve klasörler arası geçiş.
|
||||
|
||||
Responsive: Masaüstünde üç sütunlu (Klasörler - Liste - Detay), mobilde ise liste odaklı tasarım.
|
||||
|
||||
3. Temel Özellikler (MVP)
|
||||
A. Gelen Kutusu ve Klasör Yönetimi
|
||||
|
||||
Liste Görünümü: Okunmamış maillerin kalın fontla vurgulandığı, gönderen adı, konu ve tarihin görüldüğü liste alanı.
|
||||
|
||||
Klasör Desteği: Gelen Kutusu (Inbox), Taslaklar (Drafts), Gönderilenler (Sent), Çöp Kutusu (Trash) ve Arşiv arasında geçiş.
|
||||
|
||||
Arama: Gönderen adına veya konu başlığına göre hızlı filtreleme.
|
||||
|
||||
B. Okuma ve Yanıtlama
|
||||
|
||||
Rich Text Okuma: HTML formatındaki maillerin güvenli bir şekilde (XSS korumalı) görüntülenmesi.
|
||||
|
||||
Ekleri Görüntüleme: Gelen eklerin (PDF, Görsel) tarayıcı içinde önizlenmesi veya indirilmesi.
|
||||
|
||||
Yanıtla/İlet: Mailleri yanıtlama veya başkasına iletme fonksiyonları.
|
||||
|
||||
C. Mail Yazma (Compose)
|
||||
|
||||
Zengin Metin Editörü: Bold, Italic, Liste gibi temel formatlama özelliklerine sahip editör.
|
||||
|
||||
Dosya Eki: Drag-and-drop (sürükle-bırak) yöntemiyle dosya ekleme.
|
||||
|
||||
4. Teknik Mimari ve Protokoller
|
||||
IMAP Entegrasyonu: Sunucu tarafında imapflow kütüphanesi ile mail içeriklerinin çekilmesi.
|
||||
|
||||
SMTP Entegrasyonu: nodemailer ile güvenli mail gönderimi.
|
||||
|
||||
Frontend UI: shadcn/ui'ın Mail component şablonu (Bu şablon doğrudan Gmail arayüzünü referans alır).
|
||||
|
||||
Güvenlik: Kullanıcı şifreleri veritabanında saklanmaz; oturum bazlı (session) olarak IMAP bağlantısı için kullanılır.
|
||||
|
||||
5. Yol Haritası (Gelecek Özellikler)
|
||||
Push Notifications: Yeni mail geldiğinde tarayıcı veya mobil bildirimi.
|
||||
|
||||
Otomatik İmza: Kullanıcı bazlı zengin metin imzalar.
|
||||
|
||||
Spam Yönetimi: Mailcow API ile entegre "Spam olarak işaretle" butonu.
|
||||
13
lib/format.ts
Normal file
13
lib/format.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Byte cinsinden değeri okunabilir formata çevirir.
|
||||
* Örn: 3221225472 → "3 GB", 524288000 → "500 MB"
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 MB";
|
||||
const mb = bytes / (1024 * 1024);
|
||||
if (mb >= 1024) {
|
||||
const gb = mb / 1024;
|
||||
return gb % 1 === 0 ? `${gb} GB` : `${gb.toFixed(1)} GB`;
|
||||
}
|
||||
return mb % 1 === 0 ? `${mb} MB` : `${mb.toFixed(1)} MB`;
|
||||
}
|
||||
339
lib/imap.ts
Normal file
339
lib/imap.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* lib/imap.ts
|
||||
* IMAP client using imapflow — server-side only.
|
||||
* Connects with user's mailbox credentials (from session), never stored in DB.
|
||||
*/
|
||||
|
||||
import { ImapFlow } from "imapflow";
|
||||
import { simpleParser, ParsedMail } from "mailparser";
|
||||
|
||||
const IMAP_HOST = process.env.MAILCOW_API_URL
|
||||
? new URL(process.env.MAILCOW_API_URL).hostname
|
||||
: "localhost";
|
||||
const IMAP_PORT = parseInt(process.env.IMAP_PORT ?? "993");
|
||||
|
||||
export interface MailCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface MailFolder {
|
||||
name: string;
|
||||
path: string;
|
||||
specialUse?: string;
|
||||
messages: number;
|
||||
unseen: number;
|
||||
}
|
||||
|
||||
export interface MailEnvelope {
|
||||
uid: number;
|
||||
seq: number;
|
||||
subject: string;
|
||||
from: { name: string; address: string }[];
|
||||
to: { name: string; address: string }[];
|
||||
date: string;
|
||||
seen: boolean;
|
||||
flagged: boolean;
|
||||
hasAttachments: boolean;
|
||||
size: number;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
export interface MailMessage {
|
||||
uid: number;
|
||||
subject: string;
|
||||
from: { name: string; address: string }[];
|
||||
to: { name: string; address: string }[];
|
||||
cc: { name: string; address: string }[];
|
||||
date: string;
|
||||
html: string;
|
||||
text: string;
|
||||
seen: boolean;
|
||||
flagged: boolean;
|
||||
attachments: {
|
||||
filename: string;
|
||||
contentType: string;
|
||||
size: number;
|
||||
cid?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
/** Create an IMAP connection for the given credentials */
|
||||
async function createConnection(creds: MailCredentials): Promise<ImapFlow> {
|
||||
const client = new ImapFlow({
|
||||
host: IMAP_HOST,
|
||||
port: IMAP_PORT,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: creds.email,
|
||||
pass: creds.password,
|
||||
},
|
||||
logger: false,
|
||||
tls: {
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
/** List all mailbox folders */
|
||||
export async function listFolders(creds: MailCredentials): Promise<MailFolder[]> {
|
||||
const client = await createConnection(creds);
|
||||
try {
|
||||
const mailboxes = await client.list();
|
||||
const folders: MailFolder[] = [];
|
||||
|
||||
for (const mb of mailboxes) {
|
||||
try {
|
||||
const status = await client.status(mb.path, {
|
||||
messages: true,
|
||||
unseen: true,
|
||||
});
|
||||
folders.push({
|
||||
name: mb.name,
|
||||
path: mb.path,
|
||||
specialUse: mb.specialUse || undefined,
|
||||
messages: status.messages ?? 0,
|
||||
unseen: status.unseen ?? 0,
|
||||
});
|
||||
} catch {
|
||||
folders.push({
|
||||
name: mb.name,
|
||||
path: mb.path,
|
||||
specialUse: mb.specialUse || undefined,
|
||||
messages: 0,
|
||||
unseen: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return folders;
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
}
|
||||
|
||||
/** Map imapflow address to our format */
|
||||
function mapAddr(
|
||||
addrs: { name?: string; address?: string }[] | undefined
|
||||
): { name: string; address: string }[] {
|
||||
if (!addrs) return [];
|
||||
return addrs.map((a) => ({
|
||||
name: a.name ?? "",
|
||||
address: a.address ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
/** List messages in a folder (paginated) */
|
||||
export async function listMessages(
|
||||
creds: MailCredentials,
|
||||
folder: string,
|
||||
page: number = 1,
|
||||
pageSize: number = 50
|
||||
): Promise<{ messages: MailEnvelope[]; total: number }> {
|
||||
const client = await createConnection(creds);
|
||||
try {
|
||||
const lock = await client.getMailboxLock(folder);
|
||||
try {
|
||||
const total = client.mailbox?.exists ?? 0;
|
||||
if (total === 0) return { messages: [], total: 0 };
|
||||
|
||||
// Newest first: fetch from end
|
||||
const end = total;
|
||||
const start = Math.max(1, end - (page * pageSize) + 1);
|
||||
const fetchStart = Math.max(1, end - ((page - 1) * pageSize));
|
||||
const fetchEnd = Math.max(1, end - (page * pageSize) + 1);
|
||||
const range = `${fetchEnd}:${fetchStart}`;
|
||||
|
||||
const messages: MailEnvelope[] = [];
|
||||
for await (const msg of client.fetch(range, {
|
||||
uid: true,
|
||||
envelope: true,
|
||||
flags: true,
|
||||
bodyStructure: true,
|
||||
size: true,
|
||||
})) {
|
||||
const env = msg.envelope;
|
||||
messages.push({
|
||||
uid: msg.uid,
|
||||
seq: msg.seq,
|
||||
subject: env.subject ?? "(Konu yok)",
|
||||
from: mapAddr(env.from),
|
||||
to: mapAddr(env.to),
|
||||
date: env.date?.toISOString() ?? new Date().toISOString(),
|
||||
seen: msg.flags?.has("\\Seen") ?? false,
|
||||
flagged: msg.flags?.has("\\Flagged") ?? false,
|
||||
hasAttachments: hasAttachmentParts(msg.bodyStructure),
|
||||
size: msg.size ?? 0,
|
||||
preview: "",
|
||||
});
|
||||
}
|
||||
|
||||
// Reverse so newest is first
|
||||
messages.reverse();
|
||||
return { messages, total };
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if bodyStructure has attachment parts */
|
||||
function hasAttachmentParts(structure: any): boolean {
|
||||
if (!structure) return false;
|
||||
if (structure.disposition === "attachment") return true;
|
||||
if (structure.childNodes) {
|
||||
return structure.childNodes.some((n: any) => hasAttachmentParts(n));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Fetch a single message by UID with full body */
|
||||
export async function getMessage(
|
||||
creds: MailCredentials,
|
||||
folder: string,
|
||||
uid: number
|
||||
): Promise<MailMessage | null> {
|
||||
const client = await createConnection(creds);
|
||||
try {
|
||||
const lock = await client.getMailboxLock(folder);
|
||||
try {
|
||||
const raw = await client.download(String(uid), undefined, { uid: true });
|
||||
if (!raw?.content) return null;
|
||||
|
||||
const parsed: ParsedMail = await simpleParser(raw.content);
|
||||
|
||||
// Mark as seen
|
||||
await client.messageFlagsAdd(String(uid), ["\\Seen"], { uid: true });
|
||||
|
||||
return {
|
||||
uid,
|
||||
subject: parsed.subject ?? "(Konu yok)",
|
||||
from: parsed.from?.value?.map((a) => ({ name: a.name ?? "", address: a.address ?? "" })) ?? [],
|
||||
to: (Array.isArray(parsed.to) ? parsed.to : parsed.to ? [parsed.to] : [])
|
||||
.flatMap((t) => t.value?.map((a) => ({ name: a.name ?? "", address: a.address ?? "" })) ?? []),
|
||||
cc: (Array.isArray(parsed.cc) ? parsed.cc : parsed.cc ? [parsed.cc] : [])
|
||||
.flatMap((c) => c.value?.map((a) => ({ name: a.name ?? "", address: a.address ?? "" })) ?? []),
|
||||
date: parsed.date?.toISOString() ?? new Date().toISOString(),
|
||||
html: parsed.html || "",
|
||||
text: parsed.text || "",
|
||||
seen: true,
|
||||
flagged: false,
|
||||
attachments: (parsed.attachments ?? []).map((att) => ({
|
||||
filename: att.filename ?? "unnamed",
|
||||
contentType: att.contentType ?? "application/octet-stream",
|
||||
size: att.size ?? 0,
|
||||
cid: att.cid,
|
||||
})),
|
||||
};
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
}
|
||||
|
||||
/** Download attachment by filename */
|
||||
export async function getAttachment(
|
||||
creds: MailCredentials,
|
||||
folder: string,
|
||||
uid: number,
|
||||
filename: string
|
||||
): Promise<{ content: Buffer; contentType: string } | null> {
|
||||
const client = await createConnection(creds);
|
||||
try {
|
||||
const lock = await client.getMailboxLock(folder);
|
||||
try {
|
||||
const raw = await client.download(String(uid), undefined, { uid: true });
|
||||
if (!raw?.content) return null;
|
||||
|
||||
const parsed = await simpleParser(raw.content);
|
||||
const att = parsed.attachments?.find((a) => a.filename === filename);
|
||||
if (!att) return null;
|
||||
|
||||
return {
|
||||
content: att.content,
|
||||
contentType: att.contentType ?? "application/octet-stream",
|
||||
};
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
}
|
||||
|
||||
/** Move message to another folder */
|
||||
export async function moveMessage(
|
||||
creds: MailCredentials,
|
||||
fromFolder: string,
|
||||
uid: number,
|
||||
toFolder: string
|
||||
): Promise<void> {
|
||||
const client = await createConnection(creds);
|
||||
try {
|
||||
const lock = await client.getMailboxLock(fromFolder);
|
||||
try {
|
||||
await client.messageMove(String(uid), toFolder, { uid: true });
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete message (move to Trash or permanent delete) */
|
||||
export async function deleteMessage(
|
||||
creds: MailCredentials,
|
||||
folder: string,
|
||||
uid: number
|
||||
): Promise<void> {
|
||||
const client = await createConnection(creds);
|
||||
try {
|
||||
const lock = await client.getMailboxLock(folder);
|
||||
try {
|
||||
// If already in Trash, permanently delete
|
||||
if (folder.toLowerCase().includes("trash")) {
|
||||
await client.messageFlagsAdd(String(uid), ["\\Deleted"], { uid: true });
|
||||
await client.expunge();
|
||||
} else {
|
||||
// Move to Trash
|
||||
await client.messageMove(String(uid), "Trash", { uid: true });
|
||||
}
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle flag on a message */
|
||||
export async function toggleFlag(
|
||||
creds: MailCredentials,
|
||||
folder: string,
|
||||
uid: number,
|
||||
flag: string,
|
||||
add: boolean
|
||||
): Promise<void> {
|
||||
const client = await createConnection(creds);
|
||||
try {
|
||||
const lock = await client.getMailboxLock(folder);
|
||||
try {
|
||||
if (add) {
|
||||
await client.messageFlagsAdd(String(uid), [flag], { uid: true });
|
||||
} else {
|
||||
await client.messageFlagsRemove(String(uid), [flag], { uid: true });
|
||||
}
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
}
|
||||
56
lib/mail-session.ts
Normal file
56
lib/mail-session.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* lib/mail-session.ts
|
||||
* Stores/retrieves mail credentials in an encrypted httpOnly cookie.
|
||||
* Credentials never hit the database.
|
||||
*/
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const COOKIE_NAME = "ayrismail_creds";
|
||||
|
||||
export interface MailSessionData {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode credentials to base64 (in production, use proper encryption
|
||||
* with AES-256-GCM and AUTH_SECRET as key).
|
||||
*/
|
||||
function encode(data: MailSessionData): string {
|
||||
return Buffer.from(JSON.stringify(data)).toString("base64");
|
||||
}
|
||||
|
||||
function decode(token: string): MailSessionData | null {
|
||||
try {
|
||||
return JSON.parse(Buffer.from(token, "base64").toString("utf-8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Save mail credentials to cookie */
|
||||
export async function setMailSession(data: MailSessionData): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(COOKIE_NAME, encode(data), {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 60 * 60 * 24, // 24 hours
|
||||
path: "/",
|
||||
});
|
||||
}
|
||||
|
||||
/** Get mail credentials from cookie */
|
||||
export async function getMailSession(): Promise<MailSessionData | null> {
|
||||
const cookieStore = await cookies();
|
||||
const cookie = cookieStore.get(COOKIE_NAME);
|
||||
if (!cookie?.value) return null;
|
||||
return decode(cookie.value);
|
||||
}
|
||||
|
||||
/** Clear mail credentials cookie */
|
||||
export async function clearMailSession(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(COOKIE_NAME);
|
||||
}
|
||||
211
lib/mailcow.ts
Normal file
211
lib/mailcow.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* lib/mailcow.ts
|
||||
* Mailcow API client — server-side only.
|
||||
* Uses the single super-admin API key from .env.
|
||||
*/
|
||||
|
||||
const BASE = process.env.MAILCOW_API_URL?.replace(/\/$/, "") ?? "";
|
||||
const KEY = process.env.MAILCOW_API_KEY ?? "";
|
||||
|
||||
async function mfetch(path: string, options: RequestInit = {}) {
|
||||
const url = `${BASE}/api/v1${path}`;
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": KEY,
|
||||
...options.headers,
|
||||
},
|
||||
// Don't cache — always fresh
|
||||
cache: "no-store",
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────
|
||||
|
||||
export interface MailcowDomain {
|
||||
domain_name: string;
|
||||
description: string;
|
||||
active: string; // "1" | "0"
|
||||
mboxes_in_domain: number;
|
||||
mboxes_left: number;
|
||||
max_num_mboxes_for_domain: number;
|
||||
quota_used_in_domain: string;
|
||||
max_quota_for_domain: number;
|
||||
max_quota_for_mbox: number;
|
||||
aliases_in_domain: number;
|
||||
aliases_left: number;
|
||||
}
|
||||
|
||||
export interface MailcowMailbox {
|
||||
username: string; // full email e.g. info@domain.com
|
||||
name: string; // display name
|
||||
local_part: string;
|
||||
domain: string;
|
||||
quota: number; // bytes
|
||||
quota_used: number; // bytes
|
||||
active: string; // "1" | "0"
|
||||
created: string;
|
||||
modified: string;
|
||||
}
|
||||
|
||||
export interface MailcowDomainAdmin {
|
||||
username: string;
|
||||
active: string;
|
||||
domains: string[];
|
||||
created: string;
|
||||
modified: string;
|
||||
}
|
||||
|
||||
// ─── Domains ────────────────────────────────────────────────
|
||||
|
||||
export async function getDomains(): Promise<MailcowDomain[]> {
|
||||
const res = await mfetch("/get/domain/all");
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
export async function getDomain(domain: string): Promise<MailcowDomain | null> {
|
||||
const res = await mfetch(`/get/domain/${domain}`);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data[0] ?? null : data ?? null;
|
||||
}
|
||||
|
||||
export async function createDomain(payload: {
|
||||
domain: string;
|
||||
description?: string;
|
||||
aliases?: number;
|
||||
mailboxes?: number;
|
||||
defquota?: number;
|
||||
maxquota?: number;
|
||||
quota?: number;
|
||||
active?: number;
|
||||
}) {
|
||||
const body = {
|
||||
active: 1,
|
||||
aliases: 400,
|
||||
mailboxes: 10,
|
||||
defquota: 3072,
|
||||
maxquota: 10240,
|
||||
quota: 10240,
|
||||
...payload,
|
||||
};
|
||||
const res = await mfetch("/add/domain", { method: "POST", body: JSON.stringify(body) });
|
||||
const data = await res.json();
|
||||
return { ok: res.ok, data };
|
||||
}
|
||||
|
||||
export async function deleteDomain(domain: string) {
|
||||
const res = await mfetch("/delete/domain", {
|
||||
method: "POST",
|
||||
body: JSON.stringify([domain]),
|
||||
});
|
||||
const data = await res.json();
|
||||
return { ok: res.ok, data };
|
||||
}
|
||||
|
||||
// ─── Domain Admins ──────────────────────────────────────────
|
||||
|
||||
export async function getDomainAdmins(): Promise<MailcowDomainAdmin[]> {
|
||||
const res = await mfetch("/get/domain-admin/all");
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : Object.values(data);
|
||||
}
|
||||
|
||||
export async function createDomainAdmin(payload: {
|
||||
username: string;
|
||||
password: string;
|
||||
domains: string; // comma-separated or single domain
|
||||
active?: number;
|
||||
}) {
|
||||
const body = {
|
||||
active: 1,
|
||||
password2: payload.password,
|
||||
...payload,
|
||||
};
|
||||
const res = await mfetch("/add/domain-admin", { method: "POST", body: JSON.stringify(body) });
|
||||
const data = await res.json();
|
||||
return { ok: res.ok, data };
|
||||
}
|
||||
|
||||
export async function deleteDomainAdmin(username: string) {
|
||||
const res = await mfetch("/delete/domain-admin", {
|
||||
method: "POST",
|
||||
body: JSON.stringify([username]),
|
||||
});
|
||||
const data = await res.json();
|
||||
return { ok: res.ok, data };
|
||||
}
|
||||
|
||||
// ─── Mailboxes ──────────────────────────────────────────────
|
||||
|
||||
export async function getMailboxes(domain: string): Promise<MailcowMailbox[]> {
|
||||
const res = await mfetch(`/get/mailbox/all/${domain}`);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
export async function createMailbox(payload: {
|
||||
local_part: string;
|
||||
domain: string;
|
||||
name: string;
|
||||
password: string;
|
||||
quota?: number;
|
||||
active?: number;
|
||||
}) {
|
||||
const body = {
|
||||
quota: 3072,
|
||||
active: 1,
|
||||
password2: payload.password,
|
||||
...payload,
|
||||
};
|
||||
const res = await mfetch("/add/mailbox", { method: "POST", body: JSON.stringify(body) });
|
||||
const data = await res.json();
|
||||
return { ok: res.ok, data };
|
||||
}
|
||||
|
||||
export async function deleteMailbox(emails: string[]) {
|
||||
const res = await mfetch("/delete/mailbox", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(emails),
|
||||
});
|
||||
const data = await res.json();
|
||||
return { ok: res.ok, data };
|
||||
}
|
||||
|
||||
export async function editMailbox(
|
||||
emails: string[],
|
||||
attr: {
|
||||
password?: string;
|
||||
password2?: string;
|
||||
active?: number;
|
||||
quota?: number;
|
||||
name?: string;
|
||||
}
|
||||
) {
|
||||
const body = { items: emails, attr };
|
||||
const res = await mfetch("/edit/mailbox", { method: "POST", body: JSON.stringify(body) });
|
||||
const data = await res.json();
|
||||
return { ok: res.ok, data };
|
||||
}
|
||||
|
||||
// ─── Status ─────────────────────────────────────────────────
|
||||
|
||||
export async function getVersionStatus() {
|
||||
const res = await mfetch("/get/status/version");
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── DKIM ────────────────────────────────────────────────────
|
||||
|
||||
export async function getDKIM(domain: string) {
|
||||
const res = await mfetch(`/get/dkim/${domain}`);
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
}
|
||||
131
lib/smtp.ts
Normal file
131
lib/smtp.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* lib/smtp.ts
|
||||
* SMTP mail sending via nodemailer + IMAP Sent folder append.
|
||||
* Uses user's mailbox credentials for authenticated SMTP.
|
||||
*/
|
||||
|
||||
import nodemailer from "nodemailer";
|
||||
import { ImapFlow } from "imapflow";
|
||||
import type { MailCredentials } from "./imap";
|
||||
|
||||
const SMTP_HOST = process.env.MAILCOW_API_URL
|
||||
? new URL(process.env.MAILCOW_API_URL).hostname
|
||||
: "localhost";
|
||||
const SMTP_PORT = parseInt(process.env.SMTP_PORT ?? "587");
|
||||
const IMAP_HOST = SMTP_HOST;
|
||||
const IMAP_PORT = parseInt(process.env.IMAP_PORT ?? "993");
|
||||
|
||||
export interface SendMailOptions {
|
||||
to: string;
|
||||
cc?: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string;
|
||||
inReplyTo?: string;
|
||||
references?: string;
|
||||
attachments?: {
|
||||
filename: string;
|
||||
content: Buffer | string;
|
||||
contentType?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export async function sendMail(
|
||||
creds: MailCredentials,
|
||||
options: SendMailOptions
|
||||
): Promise<{ success: boolean; messageId?: string; error?: string }> {
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
secure: SMTP_PORT === 465,
|
||||
auth: {
|
||||
user: creds.email,
|
||||
pass: creds.password,
|
||||
},
|
||||
tls: {
|
||||
// Mailcow self-signed cert'e izin ver
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
});
|
||||
|
||||
const mailOptions = {
|
||||
from: creds.email,
|
||||
to: options.to,
|
||||
cc: options.cc || undefined,
|
||||
subject: options.subject,
|
||||
html: options.html,
|
||||
text: options.text || undefined,
|
||||
inReplyTo: options.inReplyTo || undefined,
|
||||
references: options.references || undefined,
|
||||
attachments: options.attachments?.map((a) => ({
|
||||
filename: a.filename,
|
||||
content: a.content,
|
||||
contentType: a.contentType,
|
||||
})),
|
||||
};
|
||||
|
||||
const result = await transporter.sendMail(mailOptions);
|
||||
|
||||
// Gönderilen maili Sent klasörüne kaydet (IMAP APPEND)
|
||||
try {
|
||||
await appendToSent(creds, mailOptions);
|
||||
} catch (e) {
|
||||
// Sent'e kaydetme başarısız olursa mail yine de gitmiş olur
|
||||
console.error("Sent klasörüne kaydetme hatası:", e);
|
||||
}
|
||||
|
||||
return { success: true, messageId: result.messageId };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err?.message ?? "SMTP hatası" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gönderilen maili IMAP Sent klasörüne kaydet.
|
||||
* Mailcow'da Sent klasörü "Sent" olarak adlandırılır.
|
||||
*/
|
||||
async function appendToSent(
|
||||
creds: MailCredentials,
|
||||
mailOptions: Record<string, any>
|
||||
): Promise<void> {
|
||||
// nodemailer ile raw mesaj oluştur
|
||||
const transporter = nodemailer.createTransport({ jsonTransport: true });
|
||||
const compiled = await transporter.sendMail(mailOptions);
|
||||
const rawMessage = JSON.parse(compiled.message);
|
||||
|
||||
// Gerçek raw RFC822 mesajı oluştur
|
||||
const buildTransporter = nodemailer.createTransport({ streamTransport: true });
|
||||
const built = await buildTransporter.sendMail(mailOptions);
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of built.message as any) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
const rawBuffer = Buffer.concat(chunks);
|
||||
|
||||
const client = new ImapFlow({
|
||||
host: IMAP_HOST,
|
||||
port: IMAP_PORT,
|
||||
secure: true,
|
||||
auth: { user: creds.email, pass: creds.password },
|
||||
logger: false,
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
try {
|
||||
// Sent klasörünü bul
|
||||
const mailboxes = await client.list();
|
||||
let sentPath = "Sent";
|
||||
for (const mb of mailboxes) {
|
||||
if (mb.specialUse === "\\Sent") {
|
||||
sentPath = mb.path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Mesajı Sent klasörüne ekle (Seen flag ile)
|
||||
await client.append(sentPath, rawBuffer, ["\\Seen"]);
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
}
|
||||
69
lib/users.ts
Normal file
69
lib/users.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* lib/users.ts
|
||||
* Reads user config from environment variables — no database needed.
|
||||
*
|
||||
* .env format:
|
||||
* USER_0_NAME="Mustafa Ayris"
|
||||
* USER_0_EMAIL="mustafa@ayristech.com"
|
||||
* USER_0_PASSWORD="mustafa123"
|
||||
* USER_0_ROLE="SUPER_ADMIN" // or "DOMAIN_ADMIN"
|
||||
* USER_0_DOMAINS="*" // "*" for all, or "domain1.com,domain2.com"
|
||||
*
|
||||
* USER_1_NAME="Emina Karabudak"
|
||||
* USER_1_EMAIL="emina@ayristech.com"
|
||||
* USER_1_PASSWORD="emina123"
|
||||
* USER_1_ROLE="DOMAIN_ADMIN"
|
||||
* USER_1_DOMAINS="aveminakarabudak.com"
|
||||
*/
|
||||
|
||||
export interface AppUser {
|
||||
id: string; // "user_0", "user_1", ...
|
||||
name: string;
|
||||
email: string;
|
||||
password: string; // plain text — store hashed in prod or use secrets manager
|
||||
role: "SUPER_ADMIN" | "DOMAIN_ADMIN";
|
||||
domains: string[]; // ["*"] for super admin, ["domain.com"] for domain admins
|
||||
}
|
||||
|
||||
/** Load all users defined in environment variables */
|
||||
export function getUsers(): AppUser[] {
|
||||
const users: AppUser[] = [];
|
||||
|
||||
let i = 0;
|
||||
while (true) {
|
||||
const name = process.env[`USER_${i}_NAME`];
|
||||
const email = process.env[`USER_${i}_EMAIL`];
|
||||
const password = process.env[`USER_${i}_PASSWORD`];
|
||||
const role = process.env[`USER_${i}_ROLE`] as AppUser["role"];
|
||||
const domainsRaw = process.env[`USER_${i}_DOMAINS`] ?? "";
|
||||
|
||||
if (!name || !email || !password) break;
|
||||
|
||||
users.push({
|
||||
id: `user_${i}`,
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
role: role ?? "DOMAIN_ADMIN",
|
||||
domains: domainsRaw === "*" ? ["*"] : domainsRaw.split(",").map((d) => d.trim()).filter(Boolean),
|
||||
});
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
/** Find user by email and validate password */
|
||||
export function authenticateUser(email: string, password: string): AppUser | null {
|
||||
const users = getUsers();
|
||||
const user = users.find((u) => u.email.toLowerCase() === email.toLowerCase());
|
||||
if (!user) return null;
|
||||
if (user.password !== password) return null;
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Check if a user has access to a specific domain */
|
||||
export function canAccessDomain(userDomains: string[], domain: string): boolean {
|
||||
return userDomains.includes("*") || userDomains.includes(domain);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
1268
package-lock.json
generated
1268
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -6,21 +6,38 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"imapflow": "^1.3.3",
|
||||
"lucide-react": "^1.14.0",
|
||||
"mailparser": "^3.9.8",
|
||||
"next": "16.2.6",
|
||||
"next-auth": "^5.0.0-beta.31",
|
||||
"nodemailer": "^8.0.7",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
"react-dom": "19.2.4",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"dotenv": "^17.4.2",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
22
proxy.ts
Normal file
22
proxy.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "./auth";
|
||||
|
||||
export default auth((req) => {
|
||||
const { nextUrl } = req;
|
||||
const isLoggedIn = !!req.auth;
|
||||
const isLoginPage = nextUrl.pathname === "/login";
|
||||
|
||||
if (!isLoggedIn && !isLoginPage) {
|
||||
return NextResponse.redirect(new URL("/login", req.url));
|
||||
}
|
||||
|
||||
if (isLoggedIn && isLoginPage) {
|
||||
return NextResponse.redirect(new URL("/dashboard", req.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
});
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
27
types/next-auth.d.ts
vendored
Normal file
27
types/next-auth.d.ts
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
import NextAuth from "next-auth";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
image?: string;
|
||||
role: string; // "SUPER_ADMIN" | "DOMAIN_ADMIN"
|
||||
domains: string[]; // ["*"] or ["domain1.com", "domain2.com"]
|
||||
};
|
||||
}
|
||||
|
||||
interface User {
|
||||
role?: string;
|
||||
domains?: string[];
|
||||
}
|
||||
}
|
||||
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
id?: string;
|
||||
role?: string;
|
||||
domains?: string[];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user