Implement database migration, notification logs, and one-click Mailcow setup

This commit is contained in:
AyrisAI
2026-05-14 16:49:11 +03:00
parent f328296c64
commit b024e20027
18 changed files with 1067 additions and 166 deletions

View File

@@ -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=&quot;Ahmet Yılmaz&quot;<br />
USER_2_EMAIL=&quot;ahmet@ayristech.com&quot;<br />
USER_2_PASSWORD=&quot;güçlü-şifre&quot;<br />
USER_2_ROLE=&quot;DOMAIN_ADMIN&quot;<br />
USER_2_DOMAINS=&quot;yenidomain.com&quot;
{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>; }