Projektstart

This commit is contained in:
2026-01-22 16:19:07 +01:00
parent 5174b88af9
commit bc7fbf8ce6
1553 changed files with 111281 additions and 141 deletions

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { apiFetch } from "./api";
import { apiFetch, createEventSourceFor } from "./api";
import { downloadFile } from "./export";
import { useTranslation } from "react-i18next";
type Tenant = {
@@ -48,8 +49,14 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
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 [exportStatus, setExportStatus] = useState<"idle" | "loading" | "done" | "failed">("idle");
const [exportJobId, setExportJobId] = useState<string | null>(null);
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);
@@ -81,14 +88,36 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
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);
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);
const source = createEventSourceFor(`exports/${result.jobId}`, token);
source.onmessage = async (event) => {
const data = JSON.parse(event.data);
if (data.status === "DONE") {
const base = import.meta.env.VITE_API_URL ?? "http://localhost:8000";
const response = await fetch(`${base}/admin/exports/${result.jobId}/download`, {
headers: { Authorization: `Bearer ${token}` }
});
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);
};
@@ -102,12 +131,7 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
});
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);
downloadFile(blob, `tenant-${tenant.id}.csv`);
setExportStatus("done");
setTimeout(() => setExportStatus("idle"), 1500);
};
@@ -170,6 +194,37 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
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">
@@ -187,6 +242,16 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
{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>
@@ -200,11 +265,24 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
<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>
{tenants.map((tenant) => (
{tenantsSorted.map((tenant) => (
<div key={tenant.id} className="list-item">
<div>
<strong>{tenant.name}</strong>
<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 })} ·{" "}
@@ -212,8 +290,7 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
</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={() => exportTenant(tenant)}>{t("adminExportStart")}</button>
<button className="ghost" onClick={() => toggleTenant(tenant)}>
{tenant.isActive ? t("adminDisable") : t("adminEnable")}
</button>
@@ -223,7 +300,13 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
))}
{exportTenantId && (
<p className="status-note">
{exportStatus === "loading" ? t("adminExporting") : exportStatus === "done" ? t("adminExportDone") : ""}
{exportStatus === "loading"
? t("adminExporting")
: exportStatus === "done"
? t("adminExportDone")
: exportStatus === "failed"
? t("adminExportFailed")
: ""}
</p>
)}
</div>
@@ -232,10 +315,25 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
{activeTab === "users" && (
<div className="card">
<h3>{t("adminUsers")}</h3>
{users.map((user) => (
<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>
<strong>{user.email}</strong>
<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">
@@ -277,10 +375,25 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
{activeTab === "accounts" && (
<div className="card">
<h3>{t("adminAccounts")}</h3>
{accounts.map((account) => (
<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>
<strong>{account.email}</strong>
<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)}>
@@ -294,10 +407,20 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
{activeTab === "jobs" && (
<div className="card">
<h3>{t("adminJobs")}</h3>
{jobs.map((job) => (
<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>{job.status}</strong>
<strong>{mapJobStatus(job.status)}</strong>
<p>{job.tenant?.name ?? "-"} · {job.mailboxAccount?.email ?? "-"}</p>
</div>
<div className="inline-actions">