485 lines
19 KiB
TypeScript
485 lines
19 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { apiFetch, createEventSourceFor } from "./api";
|
|
import { downloadFile } from "./export";
|
|
import { downloadExport } from "./exportHistory";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
type Tenant = {
|
|
id: string;
|
|
name: string;
|
|
isActive: boolean;
|
|
_count?: { users: number; mailboxAccounts: number; jobs: number };
|
|
};
|
|
|
|
type User = {
|
|
id: string;
|
|
email: string;
|
|
role: string;
|
|
isActive: boolean;
|
|
tenant?: { id: string; name: string } | null;
|
|
};
|
|
|
|
type Account = {
|
|
id: string;
|
|
email: string;
|
|
provider: string;
|
|
isActive: boolean;
|
|
tenant?: { id: string; name: string } | null;
|
|
};
|
|
|
|
type Job = {
|
|
id: string;
|
|
status: string;
|
|
createdAt: string;
|
|
tenant?: { id: string; name: string } | null;
|
|
mailboxAccount?: { id: string; email: string } | null;
|
|
};
|
|
|
|
type Props = {
|
|
token: string;
|
|
onImpersonate: (token: string) => void;
|
|
};
|
|
|
|
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" | "failed">("idle");
|
|
const [exportJobId, setExportJobId] = useState<string | null>(null);
|
|
const [exportHistory, setExportHistory] = useState<{ id: string; status: string; expiresAt?: string | null; createdAt?: string }[]>([]);
|
|
const [exportScope, setExportScope] = useState<"all" | "users" | "accounts" | "jobs" | "rules">("all");
|
|
const [exportFormat, setExportFormat] = useState<"json" | "csv" | "zip">("json");
|
|
const [tenantSort, setTenantSort] = useState<"recent" | "oldest" | "name">("recent");
|
|
const [userSort, setUserSort] = useState<"recent" | "oldest" | "email">("recent");
|
|
const [accountSort, setAccountSort] = useState<"recent" | "oldest" | "email">("recent");
|
|
const [jobSort, setJobSort] = useState<"recent" | "oldest" | "status">("recent");
|
|
|
|
const loadAll = async () => {
|
|
const tenantData = await apiFetch("/admin/tenants", {}, token);
|
|
setTenants(tenantData.tenants ?? []);
|
|
|
|
const usersData = await apiFetch("/admin/users", {}, token);
|
|
setUsers(usersData.users ?? []);
|
|
|
|
const accountsData = await apiFetch("/admin/accounts", {}, token);
|
|
setAccounts(accountsData.accounts ?? []);
|
|
|
|
const jobsData = await apiFetch("/admin/jobs", {}, token);
|
|
setJobs(jobsData.jobs ?? []);
|
|
const exportsData = await apiFetch("/admin/exports", {}, token);
|
|
setExportHistory(exportsData.exports ?? []);
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadAll().catch(() => undefined);
|
|
}, []);
|
|
|
|
const toggleTenant = async (tenant: Tenant) => {
|
|
const result = await apiFetch(
|
|
`/admin/tenants/${tenant.id}`,
|
|
{ method: "PUT", body: JSON.stringify({ isActive: !tenant.isActive }) },
|
|
token
|
|
);
|
|
setTenants((prev) => prev.map((item) => (item.id === tenant.id ? result.tenant : item)));
|
|
};
|
|
|
|
const exportTenant = async (tenant: Tenant) => {
|
|
setExportTenantId(tenant.id);
|
|
setExportStatus("loading");
|
|
if (exportFormat === "json") {
|
|
const result = await apiFetch(`/admin/tenants/${tenant.id}/export?scope=${exportScope}`, {}, token);
|
|
const blob = new Blob([JSON.stringify(result, null, 2)], { type: "application/json" });
|
|
downloadFile(blob, `tenant-${tenant.id}.json`);
|
|
} else if (exportFormat === "csv") {
|
|
await exportTenantCsv(tenant);
|
|
return;
|
|
} else {
|
|
const result = await apiFetch(`/admin/tenants/${tenant.id}/export?format=zip&scope=${exportScope}`, {}, token);
|
|
setExportJobId(result.jobId);
|
|
setExportHistory((prev) => [{ id: result.jobId, status: "QUEUED" }, ...prev]);
|
|
const source = createEventSourceFor(`exports/${result.jobId}`, token);
|
|
source.onmessage = async (event) => {
|
|
const data = JSON.parse(event.data);
|
|
setExportHistory((prev) =>
|
|
prev.map((item) => (item.id === data.id ? { ...item, status: data.status, expiresAt: data.expiresAt } : item))
|
|
);
|
|
if (data.status === "DONE") {
|
|
const response = await downloadExport(token, result.jobId);
|
|
const blob = await response.blob();
|
|
downloadFile(blob, `tenant-${tenant.id}.zip`);
|
|
setExportStatus("done");
|
|
source.close();
|
|
setTimeout(() => setExportStatus("idle"), 1500);
|
|
} else if (data.status === "FAILED") {
|
|
setExportStatus("failed");
|
|
source.close();
|
|
}
|
|
};
|
|
return;
|
|
}
|
|
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" });
|
|
downloadFile(blob, `tenant-${tenant.id}.csv`);
|
|
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}`,
|
|
{ method: "PUT", body: JSON.stringify({ isActive: !user.isActive }) },
|
|
token
|
|
);
|
|
setUsers((prev) => prev.map((item) => (item.id === user.id ? result.user : item)));
|
|
};
|
|
|
|
const toggleAccount = async (account: Account) => {
|
|
const result = await apiFetch(
|
|
`/admin/accounts/${account.id}`,
|
|
{ method: "PUT", body: JSON.stringify({ isActive: !account.isActive }) },
|
|
token
|
|
);
|
|
setAccounts((prev) => prev.map((item) => (item.id === account.id ? result.account : item)));
|
|
};
|
|
|
|
const setRole = async (user: User, role: "USER" | "ADMIN") => {
|
|
const result = await apiFetch(
|
|
`/admin/users/${user.id}/role`,
|
|
{ method: "PUT", body: JSON.stringify({ role }) },
|
|
token
|
|
);
|
|
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);
|
|
};
|
|
|
|
const sortBy = <T,>(items: T[], mode: string, getKey: (item: T) => string) => {
|
|
const sorted = [...items];
|
|
if (mode === "name" || mode === "email" || mode === "status") {
|
|
sorted.sort((a, b) => getKey(a).localeCompare(getKey(b)));
|
|
} else if (mode === "oldest") {
|
|
sorted.reverse();
|
|
}
|
|
return sorted;
|
|
};
|
|
|
|
const tenantsSorted = sortBy(tenants, tenantSort, (t) => t.name);
|
|
const usersSorted = sortBy(users, userSort, (u) => u.email);
|
|
const accountsSorted = sortBy(accounts, accountSort, (a) => a.email);
|
|
const jobsSorted = sortBy(jobs, jobSort, (j) => j.status);
|
|
const mapJobStatus = (status: string) => {
|
|
switch (status) {
|
|
case "RUNNING":
|
|
return t("statusRunning");
|
|
case "QUEUED":
|
|
return t("statusQueued");
|
|
case "SUCCEEDED":
|
|
return t("statusSucceeded");
|
|
case "FAILED":
|
|
return t("statusFailed");
|
|
case "CANCELED":
|
|
return t("statusCanceled");
|
|
default:
|
|
return status;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<section className="admin-panel">
|
|
<div className="admin-tabs">
|
|
{(["tenants", "users", "accounts", "jobs"] as const).map((tab) => (
|
|
<button
|
|
key={tab}
|
|
className={activeTab === tab ? "active" : ""}
|
|
onClick={() => setActiveTab(tab)}
|
|
>
|
|
{t(`admin${tab.charAt(0).toUpperCase()}${tab.slice(1)}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{activeTab === "tenants" && (
|
|
<div className="card">
|
|
<h3>{t("adminTenants")}</h3>
|
|
<div className="filter-row">
|
|
<label>
|
|
{t("adminSortLabel")}
|
|
<select value={tenantSort} onChange={(event) => setTenantSort(event.target.value as typeof tenantSort)}>
|
|
<option value="recent">{t("adminSortRecent")}</option>
|
|
<option value="oldest">{t("adminSortOldest")}</option>
|
|
<option value="name">{t("adminSortName")}</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<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>
|
|
<label className="toggle">
|
|
<span>{t("adminExportFormat")}</span>
|
|
<select value={exportFormat} onChange={(event) => setExportFormat(event.target.value as typeof exportFormat)}>
|
|
<option value="json">{t("exportFormatJson")}</option>
|
|
<option value="csv">{t("exportFormatCsv")}</option>
|
|
<option value="zip">{t("exportFormatZip")}</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
{tenantsSorted.map((tenant) => (
|
|
<div key={tenant.id} className="list-item">
|
|
<div>
|
|
<div className="badge">
|
|
<strong>{tenant.name}</strong>
|
|
<span className={`status-badge ${tenant.isActive ? "" : "missing"}`}>
|
|
{tenant.isActive ? t("adminActive") : t("adminInactive")}
|
|
</span>
|
|
</div>
|
|
<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("adminExportStart")}</button>
|
|
<button className="ghost" onClick={() => toggleTenant(tenant)}>
|
|
{tenant.isActive ? t("adminDisable") : t("adminEnable")}
|
|
</button>
|
|
<button className="ghost" onClick={() => deleteTenant(tenant)}>{t("adminDelete")}</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{exportTenantId && (
|
|
<p className="status-note">
|
|
{exportStatus === "loading"
|
|
? t("adminExporting")
|
|
: exportStatus === "done"
|
|
? t("adminExportDone")
|
|
: exportStatus === "failed"
|
|
? t("adminExportFailed")
|
|
: ""}
|
|
</p>
|
|
)}
|
|
{exportHistory.length > 0 && (
|
|
<div className="export-history">
|
|
<h4>{t("exportHistory")}</h4>
|
|
{exportHistory.map((item) => (
|
|
<div key={item.id} className="list-item">
|
|
<div>
|
|
<strong>{item.id.slice(0, 6)}</strong>
|
|
<p>
|
|
{item.expiresAt && new Date(item.expiresAt) < new Date()
|
|
? t("exportStatusExpired")
|
|
: item.status === "QUEUED"
|
|
? t("exportStatusQueued")
|
|
: item.status === "RUNNING"
|
|
? t("exportStatusRunning")
|
|
: item.status === "DONE"
|
|
? t("exportStatusDone")
|
|
: t("exportStatusFailed")}
|
|
</p>
|
|
</div>
|
|
<div className="inline-actions">
|
|
<span>
|
|
{item.createdAt ? new Date(item.createdAt).toLocaleString() : "-"} ·{" "}
|
|
{t("exportExpires")}: {item.expiresAt ? new Date(item.expiresAt).toLocaleString() : "-"}
|
|
</span>
|
|
<button
|
|
className="ghost"
|
|
disabled={item.status !== "DONE" || (item.expiresAt ? new Date(item.expiresAt) < new Date() : false)}
|
|
onClick={async () => {
|
|
const response = await downloadExport(token, item.id);
|
|
if (response.ok) {
|
|
const blob = await response.blob();
|
|
downloadFile(blob, `export-${item.id}.zip`);
|
|
}
|
|
}}
|
|
>
|
|
{t("exportDownload")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === "users" && (
|
|
<div className="card">
|
|
<h3>{t("adminUsers")}</h3>
|
|
<div className="filter-row">
|
|
<label>
|
|
{t("adminSortLabel")}
|
|
<select value={userSort} onChange={(event) => setUserSort(event.target.value as typeof userSort)}>
|
|
<option value="recent">{t("adminSortRecent")}</option>
|
|
<option value="oldest">{t("adminSortOldest")}</option>
|
|
<option value="email">{t("adminSortEmail")}</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
{usersSorted.map((user) => (
|
|
<div key={user.id} className="list-item">
|
|
<div>
|
|
<div className="badge">
|
|
<strong>{user.email}</strong>
|
|
<span className={`status-badge ${user.isActive ? "" : "missing"}`}>
|
|
{user.isActive ? t("adminActive") : t("adminInactive")}
|
|
</span>
|
|
</div>
|
|
<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" ? t("adminMakeUser") : t("adminMakeAdmin")}
|
|
</button>
|
|
<button className="ghost" onClick={() => toggleUser(user)}>
|
|
{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>{t("adminAccounts")}</h3>
|
|
<div className="filter-row">
|
|
<label>
|
|
{t("adminSortLabel")}
|
|
<select value={accountSort} onChange={(event) => setAccountSort(event.target.value as typeof accountSort)}>
|
|
<option value="recent">{t("adminSortRecent")}</option>
|
|
<option value="oldest">{t("adminSortOldest")}</option>
|
|
<option value="email">{t("adminSortEmail")}</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
{accountsSorted.map((account) => (
|
|
<div key={account.id} className="list-item">
|
|
<div>
|
|
<div className="badge">
|
|
<strong>{account.email}</strong>
|
|
<span className={`status-badge ${account.isActive ? "" : "missing"}`}>
|
|
{account.isActive ? t("adminActive") : t("adminInactive")}
|
|
</span>
|
|
</div>
|
|
<p>{account.provider} · {account.tenant?.name ?? "-"}</p>
|
|
</div>
|
|
<button className="ghost" onClick={() => toggleAccount(account)}>
|
|
{account.isActive ? t("adminDisable") : t("adminEnable")}
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === "jobs" && (
|
|
<div className="card">
|
|
<h3>{t("adminJobs")}</h3>
|
|
<div className="filter-row">
|
|
<label>
|
|
{t("adminSortLabel")}
|
|
<select value={jobSort} onChange={(event) => setJobSort(event.target.value as typeof jobSort)}>
|
|
<option value="recent">{t("adminSortRecent")}</option>
|
|
<option value="oldest">{t("adminSortOldest")}</option>
|
|
<option value="status">{t("adminSortStatus")}</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
{jobsSorted.map((job) => (
|
|
<div key={job.id} className="list-item">
|
|
<div>
|
|
<strong>{mapJobStatus(job.status)}</strong>
|
|
<p>{job.tenant?.name ?? "-"} · {job.mailboxAccount?.email ?? "-"}</p>
|
|
</div>
|
|
<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>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|