Add i18n support with Next.js App Router and Dictionaries

This commit is contained in:
AyrisAI
2026-05-14 12:56:43 +03:00
parent 89d74ce3fe
commit 4c9a07e3ef
24 changed files with 244 additions and 91 deletions

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

View File

@@ -0,0 +1,32 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import Providers from "@/components/Providers";
import Sidebar from "@/components/Sidebar";
import { getDictionary, Locale } from "@/app/dictionaries";
export default async function DashboardLayout(
props: {
children: React.ReactNode;
params: Promise<{ lang: string }>;
}
) {
const params = await props.params;
const {
children
} = props;
const session = await auth();
if (!session) redirect(`/${params.lang}/login`);
const dict = await getDictionary(params.lang as Locale);
return (
<Providers>
<div className="app-layout">
<Sidebar dict={dict.sidebar} lang={params.lang} />
<div className="main-content">{children}</div>
</div>
</Providers>
);
}

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,444 @@
"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 [showInfoModal, setShowInfoModal] = 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={() => setShowInfoModal(m.username)}
title="Bağlantı Bilgileri"
>
<InfoIcon />
</button>
<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>
)}
{/* Info Modal */}
{showInfoModal && (
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && setShowInfoModal(null)}>
<div className="modal">
<div className="modal-header">
<h2 className="modal-title">İstemci Bağlantı Bilgileri</h2>
<button className="modal-close" onClick={() => setShowInfoModal(null)}><XIcon /></button>
</div>
<div className="modal-body form-group">
<div style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: 10 }}>
<strong>{showInfoModal}</strong> hesabını Apple Mail, Outlook veya telefonunuza kurmak için aşağıdaki bilgileri kullanın:
</div>
<div style={{ background: "var(--bg-hover)", padding: 12, borderRadius: "var(--radius)", border: "1px solid var(--border)", fontSize: 13 }}>
<div style={{ marginBottom: 12 }}>
<div style={{ color: "var(--text-secondary)", fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: 0.5 }}>IMAP (Gelen Sunucu)</div>
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 4, alignItems: "center" }}>
<span>Sunucu: <strong>mail.ayris.tech</strong></span>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<button className="btn btn-ghost btn-sm" style={{ padding: "4px 8px", fontSize: 11 }} onClick={() => navigator.clipboard.writeText("mail.ayris.tech")} title="Kopyala"><CopyIcon /> Kopyala</button>
</div>
</div>
<div style={{ marginTop: 2 }}>Port: <strong>993</strong> (SSL/TLS)</div>
</div>
<div style={{ marginBottom: 12, paddingTop: 12, borderTop: "1px solid var(--border)" }}>
<div style={{ color: "var(--text-secondary)", fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: 0.5 }}>SMTP (Giden Sunucu)</div>
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 4, alignItems: "center" }}>
<span>Sunucu: <strong>mail.ayris.tech</strong></span>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<button className="btn btn-ghost btn-sm" style={{ padding: "4px 8px", fontSize: 11 }} onClick={() => navigator.clipboard.writeText("mail.ayris.tech")} title="Kopyala"><CopyIcon /> Kopyala</button>
</div>
</div>
<div style={{ marginTop: 2 }}>Port: <strong>587</strong> (STARTTLS) <span style={{ color: "var(--text-secondary)" }}>veya 465 (SSL)</span></div>
</div>
<div style={{ paddingTop: 12, borderTop: "1px solid var(--border)" }}>
<div style={{ color: "var(--text-secondary)", fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: 0.5 }}>Kimlik Doğrulama</div>
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 4, alignItems: "center" }}>
<span>Kullanıcı Adı: <strong>{showInfoModal}</strong></span>
<button className="btn btn-ghost btn-sm" style={{ padding: "4px 8px", fontSize: 11 }} onClick={() => navigator.clipboard.writeText(showInfoModal)} title="Kopyala"><CopyIcon /> Kopyala</button>
</div>
<div style={{ color: "var(--text-secondary)", marginTop: 4 }}>Şifre: <span style={{ fontStyle: "italic" }}>Hesap oluştururken belirlediğiniz şifre</span></div>
</div>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-primary" onClick={() => setShowInfoModal(null)}>Tamam</button>
</div>
</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>; }
function InfoIcon() { return <svg width="13" height="13" 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>; }
function CopyIcon() { return <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>; }

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

30
app/[lang]/layout.tsx Normal file
View File

@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import "../globals.css";
export const metadata: Metadata = {
title: "AyrisMail Central",
description: "Multi-tenant Mailcow yönetim paneli — AyrisTech",
};
export async function generateStaticParams() {
return [{ lang: "tr" }, { lang: "en" }];
}
export default async function RootLayout(
props: {
children: React.ReactNode;
params: Promise<{ lang: string }>;
}
) {
const params = await props.params;
const {
children
} = props;
return (
<html lang={params.lang}>
<body>{children}</body>
</html>
);
}

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 LoginForm({ dict, lang }: { dict: any; lang: string }) {
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ı."); // We can translate this later
} else {
router.push(`/${lang}/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">{dict.title || "AyrisMail Central"}</h1>
<p className="login-sub">{dict.subtitle || "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">{dict.emailLabel || "E-posta"}</label>
<input
id="email"
type="email"
className="input"
placeholder={dict.emailPlaceholder || "admin@ayristech.com"}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
</div>
<div>
<label htmlFor="password" className="label">{dict.passwordLabel || "Şifre"}</label>
<input
id="password"
type="password"
className="input"
placeholder={dict.passwordPlaceholder || "••••••••"}
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 ? (dict.signingIn || "Giriş yapılıyor...") : (dict.signInButton || "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>
);
}

13
app/[lang]/login/page.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { getDictionary, Locale } from "@/app/dictionaries";
import LoginForm from "./LoginForm";
export default async function LoginPage(
props: {
params: Promise<{ lang: string }>;
}
) {
const params = await props.params;
const dict = await getDictionary(params.lang as Locale);
return <LoginForm dict={dict.login} lang={params.lang} />;
}

17
app/[lang]/page.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
export default async function HomePage(
props: {
params: Promise<{ lang: string }>;
}
) {
const params = await props.params;
const session = await auth();
if (session) {
redirect(`/${params.lang}/dashboard`);
} else {
redirect(`/${params.lang}/login`);
}
}