Projektstart

This commit is contained in:
2026-01-22 16:04:42 +01:00
parent 57e5f652f8
commit 5174b88af9
2716 changed files with 4225555 additions and 128 deletions

View File

@@ -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>