Implement database migration, notification logs, and one-click Mailcow setup
This commit is contained in:
131
app/[lang]/dashboard/logs/page.tsx
Normal file
131
app/[lang]/dashboard/logs/page.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useDictionary } from "@/components/DictionaryContext";
|
||||
|
||||
interface Log {
|
||||
id: string;
|
||||
mailbox: string;
|
||||
sender: string | null;
|
||||
subject: string | null;
|
||||
status: string;
|
||||
error: string | null;
|
||||
createdAt: string;
|
||||
user?: {
|
||||
name: string | null;
|
||||
email: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export default function LogsPage() {
|
||||
const [logs, setLogs] = useState<Log[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const dict = useDictionary();
|
||||
|
||||
const fetchLogs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/logs");
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `HTTP error! status: ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) setLogs(data);
|
||||
} catch (error: any) {
|
||||
console.error("Failed to fetch logs:", error);
|
||||
alert(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">{dict.logs?.title || "Bildirim Logları"}</h1>
|
||||
<p className="page-subtitle">{dict.logs?.subtitle || "Son gönderilen bildirimlerin durumu"}</p>
|
||||
</div>
|
||||
<button className="btn btn-ghost" onClick={fetchLogs} disabled={loading}>
|
||||
<RefreshIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="page-body">
|
||||
<div className="table-wrap">
|
||||
{loading ? (
|
||||
<div className="empty-state">
|
||||
<span className="spinner" style={{ width: 24, height: 24 }} />
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div style={{ fontWeight: 600 }}>{dict.logs?.noLogs || "Log kaydı bulunamadı"}</div>
|
||||
</div>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{dict.logs?.mailbox || "Alıcı"}</th>
|
||||
<th>{dict.logs?.sender || "Gönderen"} / {dict.logs?.subject || "Konu"}</th>
|
||||
<th>{dict.logs?.status || "Durum"}</th>
|
||||
<th>{dict.logs?.date || "Tarih"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log) => (
|
||||
<tr key={log.id}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 500 }}>{log.mailbox}</div>
|
||||
{log.user && (
|
||||
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>
|
||||
→ {log.user.name || log.user.email}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>{log.sender || "Unknown"}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-secondary)", maxWidth: 300, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{log.subject || "(No Subject)"}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span className={`badge ${log.status === "SENT" ? "badge-green" : "badge-red"}`} style={{ width: "fit-content" }}>
|
||||
{log.status === "SENT" ? (dict.logs?.sent || "GÖNDERİLDİ") : (dict.logs?.failed || "HATA")}
|
||||
</span>
|
||||
{log.error && (
|
||||
<div style={{ fontSize: 10, color: "var(--text-red)", maxWidth: 200, wordBreak: "break-word" }}>
|
||||
{log.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
||||
{new Date(log.createdAt).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RefreshIcon() {
|
||||
return (
|
||||
<svg width="15" height="15" 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>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,13 @@ interface Domain {
|
||||
domain_name: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
telegramId?: string;
|
||||
}
|
||||
|
||||
export default function MailboxesPage() {
|
||||
const { data: session } = useSession();
|
||||
const [domains, setDomains] = useState<Domain[]>([]);
|
||||
@@ -30,12 +37,14 @@ export default function MailboxesPage() {
|
||||
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 [users, setUsers] = useState<User[]>([]);
|
||||
const [createForm, setCreateForm] = useState({ local_part: "", name: "", password: "", quota: 3072, notifyUserId: "" });
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [formError, setFormError] = useState("");
|
||||
const dict = useDictionary();
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch domains
|
||||
fetch("/api/domains")
|
||||
.then((r) => r.json())
|
||||
.then((data: Domain[]) => {
|
||||
@@ -44,6 +53,13 @@ export default function MailboxesPage() {
|
||||
setSelectedDomain(data[0].domain_name);
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch users for mapping selection
|
||||
fetch("/api/users")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (Array.isArray(data)) setUsers(data);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchMailboxes = useCallback(async (domain: string) => {
|
||||
@@ -63,21 +79,46 @@ export default function MailboxesPage() {
|
||||
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));
|
||||
try {
|
||||
const res = await fetch("/api/mailboxes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...createForm, domain: selectedDomain }),
|
||||
});
|
||||
|
||||
let data: any = {};
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch (e) {
|
||||
data = { error: "Sunucudan geçersiz yanıt geldi (JSON hatası)." };
|
||||
}
|
||||
|
||||
if (res.ok) {
|
||||
// If a notification user is selected, create the mapping
|
||||
if (createForm.notifyUserId) {
|
||||
const fullEmail = `${createForm.local_part}@${selectedDomain}`;
|
||||
await fetch("/api/mappings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: fullEmail,
|
||||
userId: createForm.notifyUserId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
setShowCreateModal(false);
|
||||
setCreateForm({ local_part: "", name: "", password: "", quota: 3072, notifyUserId: "" });
|
||||
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ı veya veritabanını kontrol edin");
|
||||
setFormError(String(msg));
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Mailbox creation failed:", error);
|
||||
setFormError(error.message);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -333,6 +374,21 @@ export default function MailboxesPage() {
|
||||
<input type="number" className="input" value={createForm.quota} min={100} max={102400}
|
||||
onChange={(e) => setCreateForm({ ...createForm, quota: parseInt(e.target.value) || 3072 })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">{dict.mailboxes.notifyUser || "Bildirim Gidecek Kullanıcı (TG)"}</label>
|
||||
<select
|
||||
className="input"
|
||||
value={createForm.notifyUserId}
|
||||
onChange={(e) => setCreateForm({ ...createForm, notifyUserId: e.target.value })}
|
||||
>
|
||||
<option value="">-- {dict.mailboxes.noNotify || "Bildirim Gönderme"} --</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name || u.email} {u.telegramId ? `(TG: ${u.telegramId})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setShowCreateModal(false)}>{dict.mailboxes.cancel || "İptal"}</button>
|
||||
|
||||
212
app/[lang]/dashboard/mappings/page.tsx
Normal file
212
app/[lang]/dashboard/mappings/page.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useDictionary } from "@/components/DictionaryContext";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface Mapping {
|
||||
id: string;
|
||||
email: string;
|
||||
userId: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export default function MappingsPage() {
|
||||
const [mappings, setMappings] = useState<Mapping[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [newUserId, setNewUserId] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const dict = useDictionary();
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [mRes, uRes] = await Promise.all([
|
||||
fetch("/api/mappings"),
|
||||
fetch("/api/users")
|
||||
]);
|
||||
const [mData, uData] = await Promise.all([mRes.json(), uRes.json()]);
|
||||
setMappings(Array.isArray(mData) ? mData : []);
|
||||
setUsers(Array.isArray(uData) ? uData : []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/mappings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: newEmail, userId: newUserId })
|
||||
});
|
||||
if (res.ok) {
|
||||
setIsModalOpen(false);
|
||||
setNewEmail("");
|
||||
setNewUserId("");
|
||||
fetchData();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("Bu eşleştirmeyi silmek istediğinize emin misiniz?")) return;
|
||||
try {
|
||||
const res = await fetch(`/api/mappings/${id}`, { method: "DELETE" });
|
||||
if (res.ok) fetchData();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = mappings.filter(m =>
|
||||
m.email.toLowerCase().includes(search.toLowerCase()) ||
|
||||
m.user.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">{dict.mappings.title}</h1>
|
||||
<p className="page-subtitle">{dict.mappings.subtitle}</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => setIsModalOpen(true)}>
|
||||
<PlusIcon />
|
||||
{dict.mappings.addMapping}
|
||||
</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={dict.mappings.searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-wrap">
|
||||
{loading ? (
|
||||
<div className="empty-state"><span className="spinner" /></div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon"><LinkIcon /></div>
|
||||
<div style={{ fontWeight: 600 }}>{dict.mappings.noMappings}</div>
|
||||
</div>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{dict.mappings.email}</th>
|
||||
<th>{dict.mappings.user}</th>
|
||||
<th style={{ width: 80 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 500 }}>{m.email}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div className="user-avatar" style={{ width: 24, height: 24, fontSize: 10 }}>
|
||||
{m.user.name[0]?.toUpperCase()}
|
||||
</div>
|
||||
<span>{m.user.name}</span>
|
||||
<span style={{ fontSize: 11, color: "var(--text-secondary)" }}>({m.user.email})</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn btn-ghost" onClick={() => handleDelete(m.id)} style={{ color: "var(--error)" }}>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content" style={{ maxWidth: 450 }}>
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">{dict.mappings.addMapping}</h2>
|
||||
<button className="modal-close" onClick={() => setIsModalOpen(false)}>×</button>
|
||||
</div>
|
||||
<form onSubmit={handleAdd} className="form-group" style={{ padding: 20 }}>
|
||||
<div>
|
||||
<label className="label">{dict.mappings.email}</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="info@domain.com"
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">{dict.mappings.user}</label>
|
||||
<select
|
||||
className="input"
|
||||
value={newUserId}
|
||||
onChange={(e) => setNewUserId(e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="">{dict.mappings.user} seçin...</option>
|
||||
{users.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.name} ({u.email})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 10, marginTop: 10 }}>
|
||||
<button type="button" className="btn btn-ghost" style={{ flex: 1 }} onClick={() => setIsModalOpen(false)}>İptal</button>
|
||||
<button type="submit" className="btn btn-primary" style={{ flex: 1 }} disabled={saving}>
|
||||
{saving ? <span className="spinner" /> : "Kaydet"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PlusIcon() { return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" 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 LinkIcon() { return <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>; }
|
||||
function TrashIcon() { return <svg width="14" height="14" 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>; }
|
||||
@@ -9,27 +9,108 @@ interface User {
|
||||
email: string;
|
||||
role: string;
|
||||
domains: string[];
|
||||
telegramId?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
role: "DOMAIN_ADMIN",
|
||||
domains: "",
|
||||
telegramId: ""
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const dict = useDictionary();
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/users");
|
||||
const data = await res.json();
|
||||
setUsers(Array.isArray(data) ? data : []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/users")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setUsers(Array.isArray(data) ? data : []);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditingUser(null);
|
||||
setFormData({ name: "", email: "", password: "", role: "DOMAIN_ADMIN", domains: "", telegramId: "" });
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (user: User) => {
|
||||
setEditingUser(user);
|
||||
setFormData({
|
||||
name: user.name || "",
|
||||
email: user.email,
|
||||
password: user.password || "",
|
||||
role: user.role,
|
||||
domains: user.domains.join(", "),
|
||||
telegramId: user.telegramId || ""
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
const url = editingUser ? `/api/users/${editingUser.id}` : "/api/users";
|
||||
const method = editingUser ? "PATCH" : "POST";
|
||||
|
||||
const payload = {
|
||||
...formData,
|
||||
domains: formData.domains.split(",").map(d => d.trim()).filter(Boolean)
|
||||
};
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setIsModalOpen(false);
|
||||
fetchUsers();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("Bu kullanıcıyı silmek istediğinize emin misiniz?")) return;
|
||||
try {
|
||||
const res = await fetch(`/api/users/${id}`, { method: "DELETE" });
|
||||
if (res.ok) fetchUsers();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = users.filter(
|
||||
(u) =>
|
||||
u.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u.name?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
@@ -37,35 +118,24 @@ export default function UsersPage() {
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">{dict.users.title || "Kullanıcılar"}</h1>
|
||||
<p className="page-subtitle">{dict.users.subtitle || "Panel kullanıcıları .env dosyasından yönetilir"}</p>
|
||||
<h1 className="page-title">{dict.users.title}</h1>
|
||||
<p className="page-subtitle">{dict.users.subtitle}</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={openAddModal}>
|
||||
<PlusIcon />
|
||||
{dict.users.addUser}
|
||||
</button>
|
||||
</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 }}>{dict.users.info ? "Info" : "Kullanıcı yönetimi hakkında"}</div>
|
||||
<div style={{ fontWeight: 600, color: "var(--accent-hover)", marginBottom: 6 }}>Kullanıcı Yönetimi</div>
|
||||
<div style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.7 }}>
|
||||
{dict.users.info || (
|
||||
<>
|
||||
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"
|
||||
{dict.users.info}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,7 +146,7 @@ export default function UsersPage() {
|
||||
<input
|
||||
type="text"
|
||||
className="input search-input"
|
||||
placeholder={dict.users.searchPlaceholder || "İsim veya e-posta ara..."}
|
||||
placeholder={dict.users.searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
@@ -85,21 +155,21 @@ export default function UsersPage() {
|
||||
|
||||
<div className="table-wrap">
|
||||
{loading ? (
|
||||
<div className="empty-state">
|
||||
<span className="spinner" style={{ width: 24, height: 24 }} />
|
||||
</div>
|
||||
<div className="empty-state"><span className="spinner" /></div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon"><UsersIcon /></div>
|
||||
<div style={{ fontWeight: 600 }}>{dict.users.noUsers || "Kullanıcı bulunamadı"}</div>
|
||||
<div style={{ fontWeight: 600 }}>{dict.users.noUsers}</div>
|
||||
</div>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{dict.users.username || "Kullanıcı"}</th>
|
||||
<th>{dict.users.role || "Rol"}</th>
|
||||
<th>{dict.users.domains || "İzin Verilen Domainler"}</th>
|
||||
<th>{dict.users.username}</th>
|
||||
<th>{dict.users.role}</th>
|
||||
<th>{dict.users.domains}</th>
|
||||
<th>Telegram ID</th>
|
||||
<th style={{ width: 100 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -108,7 +178,7 @@ export default function UsersPage() {
|
||||
<td>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<div className="user-avatar" style={{ width: 32, height: 32, fontSize: 13 }}>
|
||||
{u.name[0]?.toUpperCase()}
|
||||
{u.name ? u.name[0]?.toUpperCase() : "?"}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{u.name}</div>
|
||||
@@ -118,12 +188,12 @@ export default function UsersPage() {
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${u.role === "SUPER_ADMIN" ? "badge-blue" : "badge-green"}`}>
|
||||
{u.role === "SUPER_ADMIN" ? `★ ${dict.users.superAdmin || "Süper Admin"}` : (dict.users.domainAdmin || "Domain Admin")}
|
||||
{u.role === "SUPER_ADMIN" ? `★ ${dict.users.superAdmin}` : dict.users.domainAdmin}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{u.domains.includes("*") ? (
|
||||
<span className="badge badge-blue">{dict.users.allDomains || "Tüm domainler"}</span>
|
||||
<span className="badge badge-blue">{dict.users.allDomains}</span>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
|
||||
{u.domains.map((d) => (
|
||||
@@ -132,6 +202,15 @@ export default function UsersPage() {
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<code style={{ fontSize: 11 }}>{u.telegramId || "-"}</code>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: "flex", gap: 4 }}>
|
||||
<button className="btn btn-ghost" onClick={() => openEditModal(u)}><EditIcon /></button>
|
||||
<button className="btn btn-ghost" onClick={() => handleDelete(u.id)} style={{ color: "var(--error)" }}><TrashIcon /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -139,10 +218,63 @@ export default function UsersPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content" style={{ maxWidth: 500 }}>
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">{editingUser ? dict.users.editUser : dict.users.addUser}</h2>
|
||||
<button className="modal-close" onClick={() => setIsModalOpen(false)}>×</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="form-group" style={{ padding: 20 }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14 }}>
|
||||
<div>
|
||||
<label className="label">İsim</label>
|
||||
<input className="input" value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">E-posta</label>
|
||||
<input className="input" type="email" value={formData.email} onChange={e => setFormData({...formData, email: e.target.value})} required />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Şifre {editingUser && "(Değiştirmek istemiyorsanız boş bırakın)"}</label>
|
||||
<input className="input" type="password" value={formData.password} onChange={e => setFormData({...formData, password: e.target.value})} required={!editingUser} />
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14 }}>
|
||||
<div>
|
||||
<label className="label">Rol</label>
|
||||
<select className="input" value={formData.role} onChange={e => setFormData({...formData, role: e.target.value})}>
|
||||
<option value="SUPER_ADMIN">Süper Admin</option>
|
||||
<option value="DOMAIN_ADMIN">Domain Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Telegram ID</label>
|
||||
<input className="input" placeholder="Örn: 5009005027" value={formData.telegramId} onChange={e => setFormData({...formData, telegramId: e.target.value})} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">İzinli Domainler (Virgülle ayırın, tümü için *)</label>
|
||||
<input className="input" placeholder="domain1.com, domain2.com" value={formData.domains} onChange={e => setFormData({...formData, domains: e.target.value})} />
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 10, marginTop: 10 }}>
|
||||
<button type="button" className="btn btn-ghost" style={{ flex: 1 }} onClick={() => setIsModalOpen(false)}>İptal</button>
|
||||
<button type="submit" className="btn btn-primary" style={{ flex: 1 }} disabled={saving}>
|
||||
{saving ? <span className="spinner" /> : "Kaydet"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PlusIcon() { return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" 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 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 UsersIcon() { return <svg width="24" height="24" 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>; }
|
||||
function TrashIcon() { return <svg width="14" height="14" 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 EditIcon() { return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>; }
|
||||
|
||||
Reference in New Issue
Block a user