Compare commits

...

7 Commits

10 changed files with 453 additions and 35 deletions

View File

@@ -0,0 +1,63 @@
"use client";
import { useState } from "react";
import Providers from "@/components/Providers";
import Sidebar from "@/components/Sidebar";
import { DictionaryProvider } from "@/components/DictionaryContext";
export default function DashboardLayout({
children,
dict,
lang,
}: {
children: React.ReactNode;
dict: any;
lang: string;
}) {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
return (
<Providers>
<DictionaryProvider dictionary={dict}>
<div className={`app-layout ${isSidebarOpen ? "sidebar-open" : ""}`}>
{/* Mobile Overlay */}
<div
className="sidebar-overlay"
onClick={() => setIsSidebarOpen(false)}
/>
<Sidebar
dict={dict}
lang={lang}
onClose={() => setIsSidebarOpen(false)}
/>
<div className="main-content">
{/* Mobile Header */}
<header className="mobile-header">
<button
className="mobile-menu-btn"
onClick={() => setIsSidebarOpen(true)}
>
<MenuIcon />
</button>
<div className="mobile-logo">AyrisMail</div>
</header>
{children}
</div>
</div>
</DictionaryProvider>
</Providers>
);
}
function MenuIcon() {
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="4" x2="20" y1="12" y2="12" />
<line x1="4" x2="20" y1="6" y2="6" />
<line x1="4" x2="20" y1="18" y2="18" />
</svg>
);
}

View File

@@ -1,9 +1,7 @@
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";
import { DictionaryProvider } from "@/components/DictionaryContext";
import DashboardLayoutClient from "./DashboardLayoutClient";
export default async function DashboardLayout(
props: {
@@ -12,10 +10,7 @@ export default async function DashboardLayout(
}
) {
const params = await props.params;
const {
children
} = props;
const { children } = props;
const session = await auth();
if (!session) redirect(`/${params.lang}/login`);
@@ -23,13 +18,8 @@ export default async function DashboardLayout(
const dict = await getDictionary(params.lang as Locale);
return (
<Providers>
<DictionaryProvider dictionary={dict}>
<div className="app-layout">
<Sidebar dict={dict} lang={params.lang} />
<div className="main-content">{children}</div>
</div>
</DictionaryProvider>
</Providers>
<DashboardLayoutClient dict={dict} lang={params.lang}>
{children}
</DashboardLayoutClient>
);
}

View File

@@ -0,0 +1,196 @@
"use client";
import { useState, useEffect } from "react";
import { useDictionary } from "@/components/DictionaryContext";
import { useSession } from "next-auth/react";
export default function SettingsPage() {
const { data: session } = useSession();
const [profile, setProfile] = useState<any>(null);
const [waStatus, setWaStatus] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [fetchingQr, setFetchingQr] = useState(false);
const dict = useDictionary();
useEffect(() => {
fetchProfile();
fetchWaStatus();
const interval = setInterval(() => {
fetchWaStatus();
}, 5000);
return () => clearInterval(interval);
}, []);
const fetchProfile = async () => {
try {
const res = await fetch("/api/users/profile");
const data = await res.json();
if (data && data.error) setError(data.error);
else if (!data) setError("Kullanıcı profili bulunamadı.");
else setProfile(data);
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
};
const fetchWaStatus = async () => {
try {
const res = await fetch("/api/whatsapp/status");
const data = await res.json();
setWaStatus(data);
} catch (e) {
console.error(e);
}
};
const handleConnectWa = async () => {
setFetchingQr(true);
try {
const res = await fetch("/api/whatsapp/qr");
const data = await res.json();
setWaStatus(data);
} catch (e) {
console.error(e);
} finally {
setFetchingQr(false);
}
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const res = await fetch("/api/users/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(profile)
});
if (res.ok) alert("Ayarlar kaydedildi!");
else {
const data = await res.json();
alert("Hata: " + (data?.error || "Bilinmeyen bir hata oluştu."));
}
} catch (e: any) {
alert("Hata: " + e.message);
} finally {
setSaving(false);
}
};
if (loading) return <div className="page-body"><span className="spinner" /></div>;
if (error) return <div className="page-body"><div className="card" style={{ color: "var(--error)" }}>Hata: {error}</div></div>;
if (!profile) return <div className="page-body">Profil yüklenemedi.</div>;
return (
<>
<div className="page-header">
<div>
<h1 className="page-title">Bildirim Ayarları</h1>
<p className="page-subtitle">Telegram ve WhatsApp bildirimlerinizi buradan yönetin.</p>
</div>
</div>
<div className="page-body" style={{ maxWidth: 800 }}>
<form onSubmit={handleSave} className="form-group">
<div className="card">
<h3 style={{ marginBottom: 20, display: "flex", alignItems: "center", gap: 10 }}>
<TelegramIcon /> Telegram Bildirimleri
</h3>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 20 }}>
<input
type="checkbox"
id="tg-enabled"
checked={profile.telegramEnabled}
onChange={e => setProfile({...profile, telegramEnabled: e.target.checked})}
/>
<label htmlFor="tg-enabled">Telegram bildirimlerini aktif et</label>
</div>
<div>
<label className="label">Telegram ID</label>
<input
className="input"
value={profile.telegramId || ""}
onChange={e => setProfile({...profile, telegramId: e.target.value})}
placeholder="Örn: 5009005027"
/>
</div>
</div>
<div className="card" style={{ marginTop: 24 }}>
<h3 style={{ marginBottom: 20, display: "flex", alignItems: "center", gap: 10 }}>
<WhatsAppIcon /> WhatsApp Bildirimleri
</h3>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 20 }}>
<input
type="checkbox"
id="wa-enabled"
checked={profile.whatsappEnabled}
onChange={e => setProfile({...profile, whatsappEnabled: e.target.checked})}
/>
<label htmlFor="wa-enabled">WhatsApp bildirimlerini aktif et</label>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 24 }}>
<div>
<label className="label">Telefon Numarası</label>
<input
className="input"
value={profile.whatsappNumber || ""}
onChange={e => setProfile({...profile, whatsappNumber: e.target.value})}
placeholder="90554XXXXXXX"
/>
</div>
<div style={{ borderLeft: "1px solid var(--border)", paddingLeft: 24 }}>
<label className="label">Bağlantı Durumu</label>
{waStatus?.status === 'connected' ? (
<div style={{ color: "#10b981", fontWeight: 600, display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ width: 8, height: 8, borderRadius: "50%", background: "#10b981" }} />
Bağlı
</div>
) : (
<div>
<div style={{ color: "#ef4444", fontWeight: 600, display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
<div style={{ width: 8, height: 8, borderRadius: "50%", background: "#ef4444" }} />
Bağlı Değil
</div>
{waStatus?.qr ? (
<div style={{ background: "#fff", padding: 10, borderRadius: 8, width: "fit-content" }}>
<img src={waStatus.qr} alt="QR Code" style={{ width: 150, height: 150 }} />
<p style={{ fontSize: 11, color: "#000", textAlign: "center", marginTop: 5 }}>WhatsApp'tan okutun</p>
</div>
) : (
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={handleConnectWa}
disabled={fetchingQr}
>
{fetchingQr ? "QR Oluşturuluyor..." : "Bağlantı Kur (QR)"}
</button>
)}
</div>
)}
</div>
</div>
</div>
<div style={{ marginTop: 24, display: "flex", justifyContent: "flex-end" }}>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? <span className="spinner" /> : "Değişiklikleri Kaydet"}
</button>
</div>
</form>
</div>
</>
);
}
function TelegramIcon() { return <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg>; }
function WhatsAppIcon() { return <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5Z"/></svg>; }

View File

@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export async function GET() {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const user = await prisma.user.findUnique({
where: { id: session.user.id }
});
return NextResponse.json(user);
}
export async function PATCH(req: Request) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
try {
const body = await req.json();
const { telegramId, telegramEnabled, whatsappNumber, whatsappEnabled } = body;
const user = await prisma.user.update({
where: { id: session.user.id },
data: {
telegramId,
telegramEnabled,
whatsappNumber,
whatsappEnabled
} as any
});
return NextResponse.json(user);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -67,36 +67,45 @@ export async function POST(request: Request) {
};
// 3. Bildirim Gönder (Telegram)
const notificationResult = await sendTelegramNotification(
mapping.userId,
to,
mailData.from,
mailData.subject,
"" // Analiz bilgisini kaldırdık
);
let tgStatus = 'SKIPPED';
const user = mapping.user as any;
if (user.telegramEnabled && user.telegramId) {
const tgResult = await sendTelegramNotification(
mapping.userId,
to,
mailData.from,
mailData.subject,
""
);
tgStatus = tgResult.status;
}
// 4. Bildirim Gönder (WhatsApp)
// Şu an için varsayılan numaraya gönderiyoruz, ilerde User modeline alan eklenebilir.
const waNumber = process.env.DEFAULT_WHATSAPP_NUMBER || '905543765103';
const waMessage = `📩 *Yeni E-posta*\n\n*Gönderen:* ${mailData.from}\n*Konu:* ${mailData.subject}\n*Alıcı:* ${to}\n\n_AyrisMail Central_`;
let waStatus = 'SKIPPED';
if (user.whatsappEnabled && (user.whatsappNumber || process.env.DEFAULT_WHATSAPP_NUMBER)) {
const waNumber = user.whatsappNumber || process.env.DEFAULT_WHATSAPP_NUMBER;
const waMessage = `📩 *Yeni E-posta*\n\n*Gönderen:* ${mailData.from}\n*Konu:* ${mailData.subject}\n*Alıcı:* ${to}\n\n_AyrisMail Central_`;
await sendWA(waNumber, waMessage);
const waResult = await sendWA(waNumber, waMessage, mapping.userId);
waStatus = waResult.success ? 'SENT' : 'FAILED';
}
// 5. Bildirim Logu
await prisma.notificationLog.create({
await (prisma as any).notificationLog.create({
data: {
mailbox: to,
sender: mailData.from,
subject: mailData.subject,
status: notificationResult.status,
status: tgStatus === 'SENT' || waStatus === 'SENT' ? 'SENT' : 'FAILED',
userId: mapping.userId,
error: notificationResult.error
error: tgStatus === 'FAILED' ? 'TG Failed' : (waStatus === 'FAILED' ? 'WA Failed' : null)
}
});
return NextResponse.json({
success: true,
notification: notificationResult.status,
tgStatus,
waStatus,
subject: mailData.subject
});

View File

@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
export async function GET() {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const userId = session.user.id;
const workerUrl = process.env.WHATSAPP_WORKER_URL;
const secret = process.env.WHATSAPP_SECRET;
try {
const res = await fetch(`${workerUrl}/get-qr?userId=${userId}&secret=${secret}`);
const data = await res.json();
return NextResponse.json(data);
} catch (error: any) {
return NextResponse.json({ status: 'error', error: error.message });
}
}

View File

@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
export async function GET() {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const userId = session.user.id;
const workerUrl = process.env.WHATSAPP_WORKER_URL;
const secret = process.env.WHATSAPP_SECRET;
try {
const res = await fetch(`${workerUrl}/status?userId=${userId}&secret=${secret}`);
const data = await res.json();
return NextResponse.json(data);
} catch (error: any) {
return NextResponse.json({ status: 'error', error: error.message });
}
}

View File

@@ -1085,6 +1085,46 @@ tr:hover td {
background: var(--danger-dim);
}
/* ── Mobile Header ── */
.mobile-header {
display: none;
align-items: center;
gap: 16px;
padding: 12px 16px;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 50;
}
.mobile-menu-btn {
background: none;
border: none;
color: var(--text-primary);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.mobile-logo {
font-weight: 700;
font-size: 16px;
color: var(--accent-hover);
}
.sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
z-index: 90;
animation: fadeIn 0.2s ease;
}
/* ── Responsive ── */
@media (max-width: 1024px) {
.mail-layout { grid-template-columns: 60px 280px 1fr; }
@@ -1097,12 +1137,42 @@ tr:hover td {
}
@media (max-width: 768px) {
.sidebar { display: none; }
.mail-layout { grid-template-columns: 1fr; }
.mobile-header { display: flex; }
.sidebar {
position: fixed;
left: -240px;
top: 0;
bottom: 0;
z-index: 100;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 10px 0 30px rgba(0,0,0,0.5);
}
.sidebar-open .sidebar {
transform: translateX(240px);
}
.sidebar-open .sidebar-overlay {
display: block;
}
.mail-layout { grid-template-columns: 1fr; height: auto; overflow: visible; }
.mail-sidebar { display: none; }
.mail-detail { display: none; }
/* Show active mail view if selected */
.mail-view-active .mail-list { display: none; }
.mail-view-active .mail-detail { display: block; }
.page-body { padding: 16px; }
.page-header { padding: 16px; }
.page-header {
padding: 16px;
flex-direction: column;
align-items: flex-start;
}
.page-header .btn { width: 100%; justify-content: center; }
.stats-grid { grid-template-columns: 1fr 1fr; }
}
/* ── Language Switcher ── */

View File

@@ -5,7 +5,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import LanguageSwitcher from "./LanguageSwitcher";
export default function Sidebar({ dict, lang }: { dict: any; lang: string }) {
export default function Sidebar({ dict, lang, onClose }: { dict: any; lang: string; onClose?: () => void }) {
const { data: session } = useSession();
const pathname = usePathname();
const role = session?.user?.role ?? "";
@@ -18,6 +18,7 @@ export default function Sidebar({ dict, lang }: { dict: any; lang: string }) {
items: [
{ href: `/${lang}/dashboard`, label: dict.dashboard?.title || "Dashboard", icon: HomeIcon, roles: ["SUPER_ADMIN", "DOMAIN_ADMIN"] },
{ href: `/${lang}/dashboard/mail`, label: dict.sidebar?.mailClient || "Mail Client", icon: InboxIcon, roles: ["SUPER_ADMIN", "DOMAIN_ADMIN"] },
{ href: `/${lang}/dashboard/settings`, label: "Ayarlar", icon: SettingsIcon, roles: ["SUPER_ADMIN", "DOMAIN_ADMIN"] },
],
},
{
@@ -60,6 +61,7 @@ export default function Sidebar({ dict, lang }: { dict: any; lang: string }) {
key={item.href}
href={item.href}
className={`nav-item ${pathname === item.href ? "active" : ""}`}
onClick={onClose}
>
<item.icon />
{item.label}
@@ -176,3 +178,12 @@ function ListIcon() {
</svg>
);
}
function SettingsIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}

View File

@@ -18,6 +18,9 @@ model User {
role String @default("DOMAIN_ADMIN") // SUPER_ADMIN or DOMAIN_ADMIN
domains String[] @default([]) // ["*"] or list of domains
telegramId String?
telegramEnabled Boolean @default(true)
whatsappNumber String?
whatsappEnabled Boolean @default(false)
mailboxMappings MailboxMapping[]
notificationConfigs NotificationConfig[]
notificationLogs NotificationLog[]