Projektstart

This commit is contained in:
2026-01-22 16:40:19 +01:00
parent 43c83e96bb
commit 85dee61a4d
25 changed files with 533 additions and 157 deletions

View File

@@ -24,6 +24,10 @@ type Account = {
email: string;
provider: string;
isActive: boolean;
hasOauth?: boolean;
oauthExpiresAt?: string | null;
oauthLastCheckedAt?: string | null;
oauthLastErrorCode?: string | null;
tenant?: { id: string; name: string } | null;
};
@@ -52,7 +56,8 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
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 [exportHistory, setExportHistory] = useState<{ id: string; status: string; expiresAt?: string | null; createdAt?: string; progress?: number }[]>([]);
const [exportFilter, setExportFilter] = useState<"all" | "active" | "done" | "failed" | "expired">("all");
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");
@@ -72,8 +77,8 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
const jobsData = await apiFetch("/admin/jobs", {}, token);
setJobs(jobsData.jobs ?? []);
const exportsData = await apiFetch("/admin/exports", {}, token);
setExportHistory(exportsData.exports ?? []);
const exportsData = await apiFetch("/admin/exports", {}, token);
setExportHistory(exportsData.exports ?? []);
};
useEffect(() => {
@@ -107,7 +112,7 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
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))
prev.map((item) => (item.id === data.id ? { ...item, status: data.status, expiresAt: data.expiresAt, progress: data.progress } : item))
);
if (data.status === "DONE") {
const response = await downloadExport(token, result.jobId);
@@ -213,6 +218,14 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
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 exportsFiltered = exportHistory.filter((item) => {
const expired = item.expiresAt ? new Date(item.expiresAt) < new Date() : false;
if (exportFilter === "expired") return expired;
if (exportFilter === "active") return item.status === "QUEUED" || item.status === "RUNNING";
if (exportFilter === "done") return item.status === "DONE";
if (exportFilter === "failed") return item.status === "FAILED";
return true;
});
const mapJobStatus = (status: string) => {
switch (status) {
case "RUNNING":
@@ -317,7 +330,28 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
{exportHistory.length > 0 && (
<div className="export-history">
<h4>{t("exportHistory")}</h4>
{exportHistory.map((item) => (
<div className="filter-row">
<label>
{t("adminSortLabel")}
<select value={exportFilter} onChange={(event) => setExportFilter(event.target.value as typeof exportFilter)}>
<option value="all">{t("adminExportAll")}</option>
<option value="active">{t("exportStatusRunning")}</option>
<option value="done">{t("exportStatusDone")}</option>
<option value="failed">{t("exportStatusFailed")}</option>
<option value="expired">{t("exportStatusExpired")}</option>
</select>
</label>
<button
className="ghost"
onClick={async () => {
await apiFetch("/admin/exports/purge", { method: "POST" }, token);
loadAll().catch(() => undefined);
}}
>
{t("adminExportPurge")}
</button>
</div>
{exportsFiltered.map((item) => (
<div key={item.id} className="list-item">
<div>
<strong>{item.id.slice(0, 6)}</strong>
@@ -332,6 +366,9 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
? t("exportStatusDone")
: t("exportStatusFailed")}
</p>
{item.progress !== undefined && (
<p>{t("exportProgress", { progress: item.progress })}</p>
)}
</div>
<div className="inline-actions">
<span>
@@ -351,6 +388,15 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
>
{t("exportDownload")}
</button>
<button
className="ghost"
onClick={async () => {
await apiFetch(`/admin/exports/${item.id}`, { method: "DELETE" }, token);
loadAll().catch(() => undefined);
}}
>
{t("delete")}
</button>
</div>
</div>
))}
@@ -442,6 +488,22 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
</span>
</div>
<p>{account.provider} · {account.tenant?.name ?? "-"}</p>
{account.hasOauth && (
<p className="status-note">
{t("statusLabel")}: {account.oauthLastErrorCode ? t("badgeUnhealthy") : t("badgeHealthy")} ·{" "}
{t("adminExpiresAt")}: {account.oauthExpiresAt ? new Date(account.oauthExpiresAt).toLocaleString() : t("oauthStatusUnknown")} ·{" "}
{t("adminLastConnected")}: {account.oauthLastCheckedAt ? new Date(account.oauthLastCheckedAt).toLocaleString() : t("oauthStatusUnknown")}
</p>
)}
{account.oauthLastErrorCode && (
<p className="status-note">
{account.oauthLastErrorCode === "invalid_grant"
? t("oauthErrorInvalidGrant")
: account.oauthLastErrorCode === "token_expired"
? t("oauthErrorExpired")
: t("oauthErrorUnknown")}
</p>
)}
</div>
<button className="ghost" onClick={() => toggleAccount(account)}>
{account.isActive ? t("adminDisable") : t("adminEnable")}