Projektstart
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiFetch } from "./api";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type Tenant = {
|
||||
id: string;
|
||||
@@ -34,14 +35,21 @@ type Job = {
|
||||
|
||||
type Props = {
|
||||
token: string;
|
||||
onImpersonate: (token: string) => void;
|
||||
};
|
||||
|
||||
export default function AdminPanel({ token }: Props) {
|
||||
export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<"tenants" | "users" | "accounts" | "jobs">("tenants");
|
||||
const [resetUserId, setResetUserId] = useState<string | null>(null);
|
||||
const [resetPassword, setResetPassword] = useState("");
|
||||
const [exportTenantId, setExportTenantId] = useState<string | null>(null);
|
||||
const [exportStatus, setExportStatus] = useState<"idle" | "loading" | "done">("idle");
|
||||
const [exportScope, setExportScope] = useState<"all" | "users" | "accounts" | "jobs" | "rules">("all");
|
||||
|
||||
const loadAll = async () => {
|
||||
const tenantData = await apiFetch("/admin/tenants", {}, token);
|
||||
@@ -70,6 +78,46 @@ export default function AdminPanel({ token }: Props) {
|
||||
setTenants((prev) => prev.map((item) => (item.id === tenant.id ? result.tenant : item)));
|
||||
};
|
||||
|
||||
const exportTenant = async (tenant: Tenant) => {
|
||||
setExportTenantId(tenant.id);
|
||||
setExportStatus("loading");
|
||||
const result = await apiFetch(`/admin/tenants/${tenant.id}/export?scope=${exportScope}`, {}, token);
|
||||
const blob = new Blob([JSON.stringify(result, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `tenant-${tenant.id}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setExportStatus("done");
|
||||
setTimeout(() => setExportStatus("idle"), 1500);
|
||||
};
|
||||
|
||||
const exportTenantCsv = async (tenant: Tenant) => {
|
||||
setExportTenantId(tenant.id);
|
||||
setExportStatus("loading");
|
||||
const base = import.meta.env.VITE_API_URL ?? "http://localhost:8000";
|
||||
const response = await fetch(`${base}/admin/tenants/${tenant.id}/export?format=csv&scope=${exportScope}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
const text = await response.text();
|
||||
const blob = new Blob([text], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `tenant-${tenant.id}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setExportStatus("done");
|
||||
setTimeout(() => setExportStatus("idle"), 1500);
|
||||
};
|
||||
|
||||
const deleteTenant = async (tenant: Tenant) => {
|
||||
if (!confirm(t("adminDeleteConfirm", { name: tenant.name }))) return;
|
||||
await apiFetch(`/admin/tenants/${tenant.id}`, { method: "DELETE" }, token);
|
||||
setTenants((prev) => prev.filter((item) => item.id !== tenant.id));
|
||||
};
|
||||
|
||||
const toggleUser = async (user: User) => {
|
||||
const result = await apiFetch(
|
||||
`/admin/users/${user.id}`,
|
||||
@@ -97,6 +145,31 @@ export default function AdminPanel({ token }: Props) {
|
||||
setUsers((prev) => prev.map((item) => (item.id === user.id ? result.user : item)));
|
||||
};
|
||||
|
||||
const impersonate = async (user: User) => {
|
||||
const result = await apiFetch(`/admin/impersonate/${user.id}`, { method: "POST" }, token);
|
||||
onImpersonate(result.token);
|
||||
};
|
||||
|
||||
const resetPasswordForUser = async () => {
|
||||
if (!resetUserId || resetPassword.length < 10) return;
|
||||
await apiFetch(`/admin/users/${resetUserId}/reset`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password: resetPassword })
|
||||
}, token);
|
||||
setResetUserId(null);
|
||||
setResetPassword("");
|
||||
};
|
||||
|
||||
const cancelJob = async (job: Job) => {
|
||||
await apiFetch(`/admin/jobs/${job.id}/cancel`, { method: "POST" }, token);
|
||||
setJobs((prev) => prev.map((item) => (item.id === job.id ? { ...item, status: "CANCELED" } : item)));
|
||||
};
|
||||
|
||||
const retryJob = async (job: Job) => {
|
||||
await apiFetch(`/admin/jobs/${job.id}/retry`, { method: "POST" }, token);
|
||||
loadAll().catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="admin-panel">
|
||||
<div className="admin-tabs">
|
||||
@@ -106,31 +179,59 @@ export default function AdminPanel({ token }: Props) {
|
||||
className={activeTab === tab ? "active" : ""}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
>
|
||||
{tab}
|
||||
{t(`admin${tab.charAt(0).toUpperCase()}${tab.slice(1)}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === "tenants" && (
|
||||
<div className="card">
|
||||
<h3>Tenants</h3>
|
||||
<h3>{t("adminTenants")}</h3>
|
||||
<div className="export-panel">
|
||||
<h4>{t("adminTenantExport")}</h4>
|
||||
<p className="status-note">{t("adminExportHint")}</p>
|
||||
<label className="toggle">
|
||||
<span>{t("adminExportScope")}</span>
|
||||
<select value={exportScope} onChange={(event) => setExportScope(event.target.value as typeof exportScope)}>
|
||||
<option value="all">{t("adminExportAll")}</option>
|
||||
<option value="users">{t("adminExportUsers")}</option>
|
||||
<option value="accounts">{t("adminExportAccounts")}</option>
|
||||
<option value="jobs">{t("adminExportJobs")}</option>
|
||||
<option value="rules">{t("adminExportRules")}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{tenants.map((tenant) => (
|
||||
<div key={tenant.id} className="list-item">
|
||||
<div>
|
||||
<strong>{tenant.name}</strong>
|
||||
<p>{tenant._count?.users ?? 0} users · {tenant._count?.mailboxAccounts ?? 0} accounts · {tenant._count?.jobs ?? 0} jobs</p>
|
||||
<p>
|
||||
{t("countUsers", { count: tenant._count?.users ?? 0 })} ·{" "}
|
||||
{t("countAccounts", { count: tenant._count?.mailboxAccounts ?? 0 })} ·{" "}
|
||||
{t("countJobs", { count: tenant._count?.jobs ?? 0 })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="inline-actions">
|
||||
<button className="ghost" onClick={() => exportTenant(tenant)}>{t("adminExportJson")}</button>
|
||||
<button className="ghost" onClick={() => exportTenantCsv(tenant)}>{t("adminExportCsv")}</button>
|
||||
<button className="ghost" onClick={() => toggleTenant(tenant)}>
|
||||
{tenant.isActive ? t("adminDisable") : t("adminEnable")}
|
||||
</button>
|
||||
<button className="ghost" onClick={() => deleteTenant(tenant)}>{t("adminDelete")}</button>
|
||||
</div>
|
||||
<button className="ghost" onClick={() => toggleTenant(tenant)}>
|
||||
{tenant.isActive ? "Disable" : "Enable"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{exportTenantId && (
|
||||
<p className="status-note">
|
||||
{exportStatus === "loading" ? t("adminExporting") : exportStatus === "done" ? t("adminExportDone") : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "users" && (
|
||||
<div className="card">
|
||||
<h3>Users</h3>
|
||||
<h3>{t("adminUsers")}</h3>
|
||||
{users.map((user) => (
|
||||
<div key={user.id} className="list-item">
|
||||
<div>
|
||||
@@ -138,22 +239,44 @@ export default function AdminPanel({ token }: Props) {
|
||||
<p>{user.role} · {user.tenant?.name ?? "-"}</p>
|
||||
</div>
|
||||
<div className="inline-actions">
|
||||
<button className="ghost" onClick={() => setRole(user, user.role === "ADMIN" ? "USER" : "ADMIN")}
|
||||
>
|
||||
{user.role === "ADMIN" ? "Make USER" : "Make ADMIN"}
|
||||
<button className="ghost" onClick={() => setRole(user, user.role === "ADMIN" ? "USER" : "ADMIN")}>
|
||||
{user.role === "ADMIN" ? t("adminMakeUser") : t("adminMakeAdmin")}
|
||||
</button>
|
||||
<button className="ghost" onClick={() => toggleUser(user)}>
|
||||
{user.isActive ? "Disable" : "Enable"}
|
||||
{user.isActive ? t("adminDisable") : t("adminEnable")}
|
||||
</button>
|
||||
<button className="ghost" onClick={() => impersonate(user)}>
|
||||
{t("adminImpersonate")}
|
||||
</button>
|
||||
<button className="ghost" onClick={() => setResetUserId(user.id)}>
|
||||
{t("adminResetPassword")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{resetUserId && (
|
||||
<div className="admin-modal">
|
||||
<div className="modal-content">
|
||||
<h4>{t("adminResetTitle")}</h4>
|
||||
<input
|
||||
placeholder={t("adminResetPlaceholder")}
|
||||
type="password"
|
||||
value={resetPassword}
|
||||
onChange={(event) => setResetPassword(event.target.value)}
|
||||
/>
|
||||
<div className="inline-actions">
|
||||
<button className="ghost" onClick={() => setResetUserId(null)}>{t("adminCancel")}</button>
|
||||
<button className="primary" onClick={resetPasswordForUser}>{t("adminConfirmReset")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "accounts" && (
|
||||
<div className="card">
|
||||
<h3>Accounts</h3>
|
||||
<h3>{t("adminAccounts")}</h3>
|
||||
{accounts.map((account) => (
|
||||
<div key={account.id} className="list-item">
|
||||
<div>
|
||||
@@ -161,7 +284,7 @@ export default function AdminPanel({ token }: Props) {
|
||||
<p>{account.provider} · {account.tenant?.name ?? "-"}</p>
|
||||
</div>
|
||||
<button className="ghost" onClick={() => toggleAccount(account)}>
|
||||
{account.isActive ? "Disable" : "Enable"}
|
||||
{account.isActive ? t("adminDisable") : t("adminEnable")}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -170,14 +293,18 @@ export default function AdminPanel({ token }: Props) {
|
||||
|
||||
{activeTab === "jobs" && (
|
||||
<div className="card">
|
||||
<h3>Jobs</h3>
|
||||
<h3>{t("adminJobs")}</h3>
|
||||
{jobs.map((job) => (
|
||||
<div key={job.id} className="list-item">
|
||||
<div>
|
||||
<strong>{job.status}</strong>
|
||||
<p>{job.tenant?.name ?? "-"} · {job.mailboxAccount?.email ?? "-"}</p>
|
||||
</div>
|
||||
<span>{new Date(job.createdAt).toLocaleString()}</span>
|
||||
<div className="inline-actions">
|
||||
<button className="ghost" onClick={() => retryJob(job)}>{t("adminRetry")}</button>
|
||||
<button className="ghost" onClick={() => cancelJob(job)}>{t("adminCancelJob")}</button>
|
||||
<span>{new Date(job.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user