first commit

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

2
.gitignore vendored
View File

@@ -39,3 +39,5 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
/app/generated/prisma

54
Dockerfile Normal file
View 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"]

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

View 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
View 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 });
}

View 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 });
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 });
}

View 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 });
}

View 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
View 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);
}

View File

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

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

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

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

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

View File

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

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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; 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 = { export const metadata: Metadata = {
title: "Create Next App", title: "AyrisMail Central",
description: "Generated by create next app", description: "Multi-tenant Mailcow yönetim paneli — AyrisTech",
}; };
export default function RootLayout({ export default function RootLayout({
@@ -23,11 +12,8 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html <html lang="tr">
lang="en" <body>{children}</body>
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html> </html>
); );
} }

90
app/login/page.tsx Normal file
View 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>
);
}

View File

@@ -1,65 +1,11 @@
import Image from "next/image"; import { redirect } from "next/navigation";
import { auth } from "@/auth";
export default function Home() { export default async function HomePage() {
return ( const session = await auth();
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black"> if (session) {
<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"> redirect("/dashboard");
<Image } else {
className="dark:invert" redirect("/login");
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>
);
} }

53
auth.ts Normal file
View 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
View 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
View 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>
);
}

View 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, "&lt;")}</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, "&lt;")}</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>; }

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>; }

BIN
dev.db Normal file

Binary file not shown.

6180
docs/openapi.yaml Normal file

File diff suppressed because it is too large Load Diff

62
docs/prd.md Normal file
View File

@@ -0,0 +1,62 @@
🚀 Ürün Gereksinim Dokümanı (PRD): AyrisMail Central
1. Ürün Özeti
AyrisMail Central, Mailcow APIsini 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ııp kapatabilirler.
2. Kullanıcı Rolleri ve Yetkiler
Süper Admin (Mustafa): * Tüm domainleri ekler/siler.
Domain Adminleri 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: Mailcowun 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
}

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ output: "standalone",
}; };
export default nextConfig; export default nextConfig;

1268
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,21 +6,38 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"seed": "tsx prisma/seed.ts"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
}, },
"dependencies": { "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": "16.2.6",
"next-auth": "^5.0.0-beta.31",
"nodemailer": "^8.0.7",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/mailparser": "^3.4.6",
"@types/node": "^20", "@types/node": "^20",
"@types/nodemailer": "^8.0.0",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"dotenv": "^17.4.2",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.6", "eslint-config-next": "16.2.6",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5" "typescript": "^5"
} }
} }

22
proxy.ts Normal file
View 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
View 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[];
}
}