Add i18n support with Next.js App Router and Dictionaries
This commit is contained in:
232
app/[lang]/dashboard/domains/page.tsx
Normal file
232
app/[lang]/dashboard/domains/page.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useTransition } from "react";
|
||||
import { formatBytes } from "@/lib/format";
|
||||
|
||||
interface Domain {
|
||||
domain_name: string;
|
||||
description: string;
|
||||
active: string;
|
||||
mboxes_in_domain: number;
|
||||
mboxes_left: number;
|
||||
max_num_mboxes_for_domain: number;
|
||||
aliases_in_domain: number;
|
||||
quota_used_in_domain: string;
|
||||
max_quota_for_domain: number;
|
||||
}
|
||||
|
||||
export default function DomainsPage() {
|
||||
const [domains, setDomains] = useState<Domain[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [search, setSearch] = useState("");
|
||||
const [form, setForm] = useState({ domain: "", description: "", mailboxes: "10", quota: "10240", maxquota: "10240" });
|
||||
const [formError, setFormError] = useState("");
|
||||
|
||||
const fetchDomains = async () => {
|
||||
setLoading(true);
|
||||
const res = await fetch("/api/domains");
|
||||
if (res.ok) setDomains(await res.json());
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { fetchDomains(); }, []);
|
||||
|
||||
const handleCreate = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError("");
|
||||
startTransition(async () => {
|
||||
const res = await fetch("/api/domains", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
domain: form.domain,
|
||||
description: form.description,
|
||||
mailboxes: parseInt(form.mailboxes),
|
||||
quota: parseInt(form.quota),
|
||||
maxquota: parseInt(form.maxquota),
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
setShowModal(false);
|
||||
setForm({ domain: "", description: "", mailboxes: "10", quota: "10240", maxquota: "10240" });
|
||||
fetchDomains();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
const msg = Array.isArray(data) ? data.map((d: { msg?: string }) => d.msg).join(", ") : (data?.error ?? "Bir hata oluştu");
|
||||
setFormError(String(msg));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (domain: string) => {
|
||||
if (!confirm(`"${domain}" domainini Mailcow'dan silmek istediğinizden emin misiniz?\n\nBu işlem geri alınamaz!`)) return;
|
||||
startTransition(async () => {
|
||||
await fetch(`/api/domains/${encodeURIComponent(domain)}`, { method: "DELETE" });
|
||||
fetchDomains();
|
||||
});
|
||||
};
|
||||
|
||||
const filtered = domains.filter(
|
||||
(d) =>
|
||||
d.domain_name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
d.description?.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Domainler</h1>
|
||||
<p className="page-subtitle">Mailcow üzerindeki tüm domainleri yönetin</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => setShowModal(true)}>
|
||||
<PlusIcon /> Domain Ekle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="page-body">
|
||||
<div className="search-bar">
|
||||
<div className="search-input-wrap">
|
||||
<span className="search-icon"><SearchIcon /></span>
|
||||
<input
|
||||
type="text"
|
||||
className="input search-input"
|
||||
placeholder="Domain veya açıklama ara..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-ghost" onClick={fetchDomains}>
|
||||
<RefreshIcon /> Yenile
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="table-wrap">
|
||||
{loading ? (
|
||||
<div className="empty-state"><span className="spinner" style={{ width: 24, height: 24 }} /></div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon"><GlobeIcon size={24} /></div>
|
||||
<div style={{ fontWeight: 600 }}>Domain bulunamadı</div>
|
||||
<div style={{ fontSize: 12 }}>Mailcow'a domain eklemek için "Domain Ekle" butonuna tıklayın.</div>
|
||||
</div>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<th>Mail Kutuları</th>
|
||||
<th>Alias</th>
|
||||
<th>Kota</th>
|
||||
<th>Durum</th>
|
||||
<th>İşlemler</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((d) => {
|
||||
const quotaUsed = Number(d.quota_used_in_domain);
|
||||
const quotaTotal = d.max_quota_for_domain;
|
||||
const pct = quotaTotal > 0 ? Math.min((quotaUsed / quotaTotal) * 100, 100) : 0;
|
||||
return (
|
||||
<tr key={d.domain_name}>
|
||||
<td>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: 6, background: "var(--accent-dim)", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--accent-hover)", flexShrink: 0 }}>
|
||||
<GlobeIcon />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{d.domain_name}</div>
|
||||
{d.description && <div style={{ fontSize: 11, color: "var(--text-secondary)" }}>{d.description}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{d.mboxes_in_domain}
|
||||
<span style={{ color: "var(--text-muted)", fontSize: 12 }}> / {d.max_num_mboxes_for_domain}</span>
|
||||
</td>
|
||||
<td>{d.aliases_in_domain}</td>
|
||||
<td style={{ minWidth: 140 }}>
|
||||
<div style={{ fontSize: 11, color: "var(--text-secondary)", marginBottom: 4 }}>
|
||||
{formatBytes(quotaUsed)} / {formatBytes(quotaTotal)}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<div className="progress-bar">
|
||||
<div className={`progress-fill ${pct > 80 ? "danger" : ""}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: "var(--text-muted)" }}>{Math.round(pct)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${String(d.active) === "1" ? "badge-green" : "badge-red"}`}>
|
||||
{String(d.active) === "1" ? "● Aktif" : "● Pasif"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(d.domain_name)} disabled={isPending}>
|
||||
<TrashIcon /> Sil
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && setShowModal(false)}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">Mailcow'a Domain Ekle</h2>
|
||||
<button className="modal-close" onClick={() => setShowModal(false)}><XIcon /></button>
|
||||
</div>
|
||||
<form onSubmit={handleCreate}>
|
||||
<div className="modal-body form-group">
|
||||
{formError && <div className="error-msg">{formError}</div>}
|
||||
<div>
|
||||
<label className="label">Domain Adı</label>
|
||||
<input type="text" className="input" placeholder="ornek.com" value={form.domain}
|
||||
onChange={(e) => setForm({ ...form, domain: e.target.value })} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Açıklama (isteğe bağlı)</label>
|
||||
<input type="text" className="input" placeholder="Bu domain hakkında..." value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div>
|
||||
<label className="label">Maks. Mailbox</label>
|
||||
<input type="number" className="input" value={form.mailboxes} min={1}
|
||||
onChange={(e) => setForm({ ...form, mailboxes: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Toplam Kota (MB)</label>
|
||||
<input type="number" className="input" value={form.quota} min={1}
|
||||
onChange={(e) => setForm({ ...form, quota: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setShowModal(false)}>İptal</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={isPending}>
|
||||
{isPending ? <span className="spinner" /> : <PlusIcon />} Ekle
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PlusIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>; }
|
||||
function SearchIcon() { return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>; }
|
||||
function RefreshIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/></svg>; }
|
||||
function GlobeIcon({ size = 13 }: { size?: number }) { return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/><path d="M2 12h20"/></svg>; }
|
||||
function TrashIcon() { return <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>; }
|
||||
function XIcon() { return <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>; }
|
||||
32
app/[lang]/dashboard/layout.tsx
Normal file
32
app/[lang]/dashboard/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
200
app/[lang]/dashboard/mail/page.tsx
Normal file
200
app/[lang]/dashboard/mail/page.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import MailLogin from "@/components/mail/MailLogin";
|
||||
import FolderList from "@/components/mail/FolderList";
|
||||
import MessageList from "@/components/mail/MessageList";
|
||||
import MessageView from "@/components/mail/MessageView";
|
||||
import ComposeModal from "@/components/mail/ComposeModal";
|
||||
|
||||
export interface MailFolder {
|
||||
name: string;
|
||||
path: string;
|
||||
specialUse?: string;
|
||||
messages: number;
|
||||
unseen: number;
|
||||
}
|
||||
|
||||
export interface MailEnvelope {
|
||||
uid: number;
|
||||
subject: string;
|
||||
from: { name: string; address: string }[];
|
||||
to: { name: string; address: string }[];
|
||||
date: string;
|
||||
seen: boolean;
|
||||
flagged: boolean;
|
||||
hasAttachments: boolean;
|
||||
}
|
||||
|
||||
export interface MailMessage extends MailEnvelope {
|
||||
cc: { name: string; address: string }[];
|
||||
html: string;
|
||||
text: string;
|
||||
attachments: { filename: string; contentType: string; size: number }[];
|
||||
}
|
||||
|
||||
export default function MailPage() {
|
||||
const [connected, setConnected] = useState<boolean | null>(null);
|
||||
const [email, setEmail] = useState("");
|
||||
const [folders, setFolders] = useState<MailFolder[]>([]);
|
||||
const [activeFolder, setActiveFolder] = useState("INBOX");
|
||||
const [messages, setMessages] = useState<MailEnvelope[]>([]);
|
||||
const [selectedUid, setSelectedUid] = useState<number | null>(null);
|
||||
const [openMessage, setOpenMessage] = useState<MailMessage | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCompose, setShowCompose] = useState(false);
|
||||
const [replyTo, setReplyTo] = useState<MailMessage | null>(null);
|
||||
|
||||
// Check connection
|
||||
useEffect(() => {
|
||||
fetch("/api/mail/auth")
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
setConnected(d.connected);
|
||||
if (d.email) setEmail(d.email);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Load folders
|
||||
const loadFolders = useCallback(async () => {
|
||||
const res = await fetch("/api/mail/folders");
|
||||
if (res.ok) setFolders(await res.json());
|
||||
}, []);
|
||||
|
||||
// Load messages
|
||||
const loadMessages = useCallback(async (folder: string) => {
|
||||
setLoading(true);
|
||||
const res = await fetch(`/api/mail/messages?folder=${encodeURIComponent(folder)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setMessages(data.messages ?? []);
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (connected) {
|
||||
loadFolders();
|
||||
loadMessages(activeFolder);
|
||||
}
|
||||
}, [connected, activeFolder, loadFolders, loadMessages]);
|
||||
|
||||
// Open message
|
||||
const openMsg = async (uid: number) => {
|
||||
setSelectedUid(uid);
|
||||
const res = await fetch(`/api/mail/messages/${uid}?folder=${encodeURIComponent(activeFolder)}`);
|
||||
if (res.ok) {
|
||||
const msg = await res.json();
|
||||
setOpenMessage(msg);
|
||||
// Mark as read in list
|
||||
setMessages((prev) => prev.map((m) => m.uid === uid ? { ...m, seen: true } : m));
|
||||
}
|
||||
};
|
||||
|
||||
// Delete
|
||||
const handleDelete = async (uid: number) => {
|
||||
await fetch("/api/mail/messages", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "delete", folder: activeFolder, uid }),
|
||||
});
|
||||
setMessages((prev) => prev.filter((m) => m.uid !== uid));
|
||||
if (selectedUid === uid) { setSelectedUid(null); setOpenMessage(null); }
|
||||
};
|
||||
|
||||
// Reply
|
||||
const handleReply = (msg: MailMessage) => {
|
||||
setReplyTo(msg);
|
||||
setShowCompose(true);
|
||||
};
|
||||
|
||||
// Disconnect
|
||||
const handleDisconnect = async () => {
|
||||
await fetch("/api/mail/auth", { method: "DELETE" });
|
||||
setConnected(false);
|
||||
setEmail("");
|
||||
setFolders([]);
|
||||
setMessages([]);
|
||||
setOpenMessage(null);
|
||||
};
|
||||
|
||||
if (connected === null) {
|
||||
return <div className="empty-state"><span className="spinner" style={{ width: 24, height: 24 }} /></div>;
|
||||
}
|
||||
|
||||
if (!connected) {
|
||||
return <MailLogin onSuccess={(e) => { setConnected(true); setEmail(e); }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mail-layout">
|
||||
<div className="mail-sidebar">
|
||||
<button className="btn btn-primary" style={{ width: "100%" }} onClick={() => { setReplyTo(null); setShowCompose(true); }}>
|
||||
<ComposeIcon /> Yeni Mail
|
||||
</button>
|
||||
<FolderList
|
||||
folders={folders}
|
||||
active={activeFolder}
|
||||
onSelect={(f) => { setActiveFolder(f); setSelectedUid(null); setOpenMessage(null); }}
|
||||
/>
|
||||
<div className="mail-account">
|
||||
<div className="mail-account-email">{email}</div>
|
||||
<button className="btn btn-ghost btn-sm" onClick={handleDisconnect} style={{ fontSize: 11 }}>
|
||||
Çıkış
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mail-list">
|
||||
<div className="mail-list-header">
|
||||
<h2>
|
||||
{folders.find((f) => f.path === activeFolder)?.name ?? activeFolder}
|
||||
</h2>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => loadMessages(activeFolder)}>↻</button>
|
||||
</div>
|
||||
<MessageList
|
||||
messages={messages}
|
||||
loading={loading}
|
||||
selectedUid={selectedUid}
|
||||
onSelect={openMsg}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mail-detail">
|
||||
{openMessage ? (
|
||||
<MessageView
|
||||
message={openMessage}
|
||||
onReply={() => handleReply(openMessage)}
|
||||
onDelete={() => handleDelete(openMessage.uid)}
|
||||
folder={activeFolder}
|
||||
/>
|
||||
) : (
|
||||
<div className="mail-empty">
|
||||
<div className="mail-empty-icon">
|
||||
<MailBigIcon />
|
||||
</div>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, color: "var(--text-secondary)" }}>Bir mail seçin</div>
|
||||
<div style={{ fontSize: 12 }}>Okumak için soldaki listeden bir mail seçin</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCompose && (
|
||||
<ComposeModal
|
||||
replyTo={replyTo}
|
||||
onClose={() => { setShowCompose(false); setReplyTo(null); }}
|
||||
onSent={() => { setShowCompose(false); setReplyTo(null); loadMessages(activeFolder); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MailBigIcon() {
|
||||
return <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.5 }}><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>;
|
||||
}
|
||||
|
||||
function ComposeIcon() {
|
||||
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>;
|
||||
}
|
||||
444
app/[lang]/dashboard/mailboxes/page.tsx
Normal file
444
app/[lang]/dashboard/mailboxes/page.tsx
Normal 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>; }
|
||||
211
app/[lang]/dashboard/page.tsx
Normal file
211
app/[lang]/dashboard/page.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { auth } from "@/auth";
|
||||
import { getDomains } from "@/lib/mailcow";
|
||||
import { canAccessDomain } from "@/lib/users";
|
||||
import { formatBytes } from "@/lib/format";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await auth();
|
||||
const role = session?.user?.role;
|
||||
const userDomains = session?.user?.domains ?? [];
|
||||
|
||||
const allDomains = await getDomains();
|
||||
const visibleDomains = allDomains.filter((d) => canAccessDomain(userDomains, d.domain_name));
|
||||
|
||||
const totalMailboxes = visibleDomains.reduce((sum, d) => sum + d.mboxes_in_domain, 0);
|
||||
const totalAliases = visibleDomains.reduce((sum, d) => sum + d.aliases_in_domain, 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Dashboard</h1>
|
||||
<p className="page-subtitle">Hoş geldiniz, {session?.user?.name} 👋</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-body">
|
||||
<div className="stats-grid">
|
||||
<StatCard
|
||||
label="Toplam Domain"
|
||||
value={visibleDomains.length}
|
||||
color="var(--accent)"
|
||||
icon={<GlobeIcon />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Mail Kutuları"
|
||||
value={totalMailboxes}
|
||||
color="var(--success)"
|
||||
icon={<MailIcon />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Alias"
|
||||
value={totalAliases}
|
||||
color="var(--warning)"
|
||||
icon={<AtIcon />}
|
||||
/>
|
||||
{role === "SUPER_ADMIN" && (
|
||||
<StatCard
|
||||
label="Tanımlı Kullanıcı"
|
||||
value={"—"}
|
||||
sub="Kullanıcılar .env'den yönetilir"
|
||||
color="var(--text-muted)"
|
||||
icon={<UsersIcon />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Domain durum tablosu */}
|
||||
{visibleDomains.length > 0 && (
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div style={{ padding: "16px 20px", borderBottom: "1px solid var(--border)" }}>
|
||||
<h2 style={{ fontSize: 14, fontWeight: 700, color: "var(--text-primary)" }}>
|
||||
Domain Durumu
|
||||
</h2>
|
||||
</div>
|
||||
<div className="table-wrap" style={{ border: "none", borderRadius: 0 }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<th>Mail Kutuları</th>
|
||||
<th>Kota Kullanımı</th>
|
||||
<th>Durum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleDomains.map((d) => {
|
||||
const quotaUsed = Number(d.quota_used_in_domain);
|
||||
const quotaTotal = d.max_quota_for_domain;
|
||||
const pct = quotaTotal > 0 ? Math.min((quotaUsed / quotaTotal) * 100, 100) : 0;
|
||||
return (
|
||||
<tr key={d.domain_name}>
|
||||
<td>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: 6, background: "var(--accent-dim)", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--accent-hover)", flexShrink: 0 }}>
|
||||
<GlobeIcon />
|
||||
</div>
|
||||
<span style={{ fontWeight: 500 }}>{d.domain_name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span>{d.mboxes_in_domain}</span>
|
||||
<span style={{ color: "var(--text-muted)", fontSize: 12 }}> / {d.max_num_mboxes_for_domain}</span>
|
||||
</td>
|
||||
<td style={{ minWidth: 160 }}>
|
||||
<div style={{ fontSize: 11, color: "var(--text-secondary)", marginBottom: 4 }}>
|
||||
{formatBytes(quotaUsed)} / {formatBytes(quotaTotal)}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div className="progress-bar">
|
||||
<div className={`progress-fill ${pct > 80 ? "danger" : ""}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: "var(--text-muted)", flexShrink: 0 }}>{Math.round(pct)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${String(d.active) === "1" ? "badge-green" : "badge-red"}`}>
|
||||
{String(d.active) === "1" ? "● Aktif" : "● Pasif"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="card">
|
||||
<h2 style={{ fontSize: 14, fontWeight: 700, marginBottom: 16, color: "var(--text-primary)" }}>
|
||||
Hızlı İşlemler
|
||||
</h2>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{role === "SUPER_ADMIN" && (
|
||||
<QuickItem
|
||||
href="/dashboard/domains"
|
||||
icon={<GlobeIcon />}
|
||||
title="Domain Yönetimi"
|
||||
desc="Domain ekle, sil, yönet"
|
||||
color="var(--accent)"
|
||||
/>
|
||||
)}
|
||||
<QuickItem
|
||||
href="/dashboard/mailboxes"
|
||||
icon={<MailIcon />}
|
||||
title="Mail Hesapları"
|
||||
desc="Yeni hesap oluştur, şifre değiştir, sil"
|
||||
color="var(--success)"
|
||||
/>
|
||||
{role === "SUPER_ADMIN" && (
|
||||
<QuickItem
|
||||
href="/dashboard/users"
|
||||
icon={<UsersIcon />}
|
||||
title="Kullanıcılar"
|
||||
desc=".env'den tanımlı panel kullanıcılarını görüntüle"
|
||||
color="var(--warning)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, sub, color, icon }: {
|
||||
label: string;
|
||||
value: number | string;
|
||||
sub?: string;
|
||||
color: string;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon" style={{ background: `${color}20`, color }}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="stat-label">{label}</div>
|
||||
<div className="stat-value">{value}</div>
|
||||
{sub && <div style={{ fontSize: 11, color: "var(--text-muted)" }}>{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickItem({ href, icon, title, desc, color }: {
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
desc: string;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 14, padding: 14,
|
||||
borderRadius: "var(--radius)", border: "1px solid var(--border)",
|
||||
background: "var(--bg)", textDecoration: "none",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 8, background: `${color}20`, color, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
|
||||
{icon}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--text-primary)" }}>{title}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-secondary)", marginTop: 2 }}>{desc}</div>
|
||||
</div>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// Icons
|
||||
function GlobeIcon() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/><path d="M2 12h20"/></svg>; }
|
||||
function MailIcon() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>; }
|
||||
function AtIcon() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94"/></svg>; }
|
||||
function UsersIcon() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>; }
|
||||
142
app/[lang]/dashboard/users/page.tsx
Normal file
142
app/[lang]/dashboard/users/page.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
domains: string[];
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/users")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setUsers(Array.isArray(data) ? data : []);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const filtered = users.filter(
|
||||
(u) =>
|
||||
u.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Kullanıcılar</h1>
|
||||
<p className="page-subtitle">Panel kullanıcıları .env dosyasından yönetilir</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-body">
|
||||
{/* Info card */}
|
||||
<div className="card" style={{ display: "flex", gap: 14, alignItems: "flex-start", border: "1px solid var(--accent-dim)", background: "var(--accent-dim)" }}>
|
||||
<div style={{ color: "var(--accent-hover)", flexShrink: 0, paddingTop: 2 }}>
|
||||
<InfoIcon />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, color: "var(--accent-hover)", marginBottom: 6 }}>Kullanıcı yönetimi hakkında</div>
|
||||
<div style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.7 }}>
|
||||
Kullanıcılar <code style={{ background: "var(--bg)", padding: "1px 6px", borderRadius: 4, fontSize: 12 }}>.env</code> dosyasındaki{" "}
|
||||
<code style={{ background: "var(--bg)", padding: "1px 6px", borderRadius: 4, fontSize: 12 }}>USER_0_*</code>,{" "}
|
||||
<code style={{ background: "var(--bg)", padding: "1px 6px", borderRadius: 4, fontSize: 12 }}>USER_1_*</code>… değişkenleriyle tanımlanır.
|
||||
Yeni kullanıcı eklemek için .env dosyasını düzenleyip uygulamayı yeniden başlatın.
|
||||
</div>
|
||||
<div style={{ marginTop: 12, padding: "10px 14px", background: "var(--bg)", borderRadius: "var(--radius)", fontSize: 12, fontFamily: "monospace", color: "var(--text-secondary)", lineHeight: 2 }}>
|
||||
USER_2_NAME="Ahmet Yılmaz"<br />
|
||||
USER_2_EMAIL="ahmet@ayristech.com"<br />
|
||||
USER_2_PASSWORD="güçlü-şifre"<br />
|
||||
USER_2_ROLE="DOMAIN_ADMIN"<br />
|
||||
USER_2_DOMAINS="yenidomain.com"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="search-bar">
|
||||
<div className="search-input-wrap">
|
||||
<span className="search-icon"><SearchIcon /></span>
|
||||
<input
|
||||
type="text"
|
||||
className="input search-input"
|
||||
placeholder="İsim veya e-posta ara..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-wrap">
|
||||
{loading ? (
|
||||
<div className="empty-state">
|
||||
<span className="spinner" style={{ width: 24, height: 24 }} />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon"><UsersIcon /></div>
|
||||
<div style={{ fontWeight: 600 }}>Kullanıcı bulunamadı</div>
|
||||
</div>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kullanıcı</th>
|
||||
<th>Rol</th>
|
||||
<th>İzin Verilen Domainler</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<div className="user-avatar" style={{ width: 32, height: 32, fontSize: 13 }}>
|
||||
{u.name[0]?.toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{u.name}</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>{u.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${u.role === "SUPER_ADMIN" ? "badge-blue" : "badge-green"}`}>
|
||||
{u.role === "SUPER_ADMIN" ? "★ Süper Admin" : "Domain Admin"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{u.domains.includes("*") ? (
|
||||
<span className="badge badge-blue">Tüm domainler</span>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
|
||||
{u.domains.map((d) => (
|
||||
<span key={d} className="badge badge-green">{d}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchIcon() { return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>; }
|
||||
function UsersIcon() { return <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>; }
|
||||
function InfoIcon() { return <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>; }
|
||||
Reference in New Issue
Block a user