Projektstart
This commit is contained in:
14
frontend/node_modules/.package-lock.json
generated
vendored
14
frontend/node_modules/.package-lock.json
generated
vendored
@@ -383,6 +383,20 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz",
|
||||
"integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
|
||||
3
frontend/node_modules/@rollup/rollup-linux-x64-musl/README.md
generated
vendored
Normal file
3
frontend/node_modules/@rollup/rollup-linux-x64-musl/README.md
generated
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# `@rollup/rollup-linux-x64-musl`
|
||||
|
||||
This is the **x86_64-unknown-linux-musl** binary for `rollup`
|
||||
25
frontend/node_modules/@rollup/rollup-linux-x64-musl/package.json
generated
vendored
Normal file
25
frontend/node_modules/@rollup/rollup-linux-x64-musl/package.json
generated
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@rollup/rollup-linux-x64-musl",
|
||||
"version": "4.56.0",
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"files": [
|
||||
"rollup.linux-x64-musl.node"
|
||||
],
|
||||
"description": "Native bindings for Rollup",
|
||||
"author": "Lukas Taegert-Atkinson",
|
||||
"homepage": "https://rollupjs.org/",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/rollup/rollup.git"
|
||||
},
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"main": "./rollup.linux-x64-musl.node"
|
||||
}
|
||||
BIN
frontend/node_modules/@rollup/rollup-linux-x64-musl/rollup.linux-x64-musl.node
generated
vendored
Normal file
BIN
frontend/node_modules/@rollup/rollup-linux-x64-musl/rollup.linux-x64-musl.node
generated
vendored
Normal file
Binary file not shown.
@@ -12,6 +12,8 @@ type Account = {
|
||||
id: string;
|
||||
email: string;
|
||||
provider: string;
|
||||
oauthConnected?: boolean;
|
||||
oauthExpiresAt?: string | null;
|
||||
};
|
||||
|
||||
type Rule = {
|
||||
@@ -84,7 +86,18 @@ export default function App() {
|
||||
setTenant(me.tenant);
|
||||
|
||||
const accountsData = await apiFetch("/mail/accounts", {}, authToken);
|
||||
setAccounts(accountsData.accounts ?? []);
|
||||
const enriched = await Promise.all((accountsData.accounts ?? []).map(async (account: Account) => {
|
||||
if (account.provider === "GMAIL") {
|
||||
try {
|
||||
const status = await apiFetch(`/oauth/gmail/status/${account.id}`, {}, authToken);
|
||||
return { ...account, oauthConnected: status.connected, oauthExpiresAt: status.expiresAt };
|
||||
} catch {
|
||||
return { ...account, oauthConnected: false };
|
||||
}
|
||||
}
|
||||
return account;
|
||||
}));
|
||||
setAccounts(enriched);
|
||||
if (!cleanupAccountId && accountsData.accounts?.length) {
|
||||
setCleanupAccountId(accountsData.accounts[0].id);
|
||||
}
|
||||
@@ -104,6 +117,23 @@ export default function App() {
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
const interval = setInterval(() => {
|
||||
loadInitial(token).catch(() => undefined);
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (window.location.pathname === "/oauth-success" || params.get("oauth") === "success") {
|
||||
window.history.replaceState({}, "", "/");
|
||||
loadInitial(token).catch(() => undefined);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedJobId || !token) return;
|
||||
apiFetch(`/jobs/${selectedJobId}/events`, {}, token)
|
||||
@@ -215,6 +245,39 @@ export default function App() {
|
||||
const addCondition = () => setConditions((prev) => [...prev, { ...defaultCondition }]);
|
||||
const addAction = () => setActions((prev) => [...prev, { ...defaultAction }]);
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
const handleImpersonate = (impersonationToken: string) => {
|
||||
localStorage.setItem("token", impersonationToken);
|
||||
setToken(impersonationToken);
|
||||
};
|
||||
|
||||
const startGmailOauth = async (accountId: string) => {
|
||||
const result = await apiFetch(
|
||||
"/oauth/gmail/url",
|
||||
{ method: "POST", body: JSON.stringify({ accountId }) },
|
||||
token
|
||||
);
|
||||
if (result.url) {
|
||||
window.location.href = result.url;
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="app auth">
|
||||
@@ -247,18 +310,18 @@ export default function App() {
|
||||
<p>{t("description")}</p>
|
||||
{authMode === "register" && (
|
||||
<input
|
||||
placeholder="Tenant Name"
|
||||
placeholder={t("tenantName")}
|
||||
value={tenantName}
|
||||
onChange={(event) => setTenantName(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
placeholder="Email"
|
||||
placeholder={t("email")}
|
||||
value={authEmail}
|
||||
onChange={(event) => setAuthEmail(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
placeholder="Passwort"
|
||||
placeholder={t("password")}
|
||||
type="password"
|
||||
value={authPassword}
|
||||
onChange={(event) => setAuthPassword(event.target.value)}
|
||||
@@ -342,35 +405,40 @@ export default function App() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{user?.role === "ADMIN" && <AdminPanel token={token} />}
|
||||
{user?.role === "ADMIN" && <AdminPanel token={token} onImpersonate={handleImpersonate} />}
|
||||
|
||||
<section className="grid">
|
||||
<article className="card">
|
||||
<h3>Mailbox hinzufügen</h3>
|
||||
<h3>{t("mailboxAdd")}</h3>
|
||||
<input
|
||||
placeholder="email@example.com"
|
||||
placeholder={t("placeholderEmail")}
|
||||
value={accountEmail}
|
||||
onChange={(event) => setAccountEmail(event.target.value)}
|
||||
/>
|
||||
<select value={accountProvider} onChange={(event) => setAccountProvider(event.target.value)}>
|
||||
<option value="GMAIL">Gmail</option>
|
||||
<option value="GMX">GMX</option>
|
||||
<option value="WEBDE">web.de</option>
|
||||
<option value="GMAIL">{t("providerGmail")}</option>
|
||||
<option value="GMX">{t("providerGmx")}</option>
|
||||
<option value="WEBDE">{t("providerWebde")}</option>
|
||||
</select>
|
||||
<input
|
||||
placeholder="App Passwort / OAuth Token"
|
||||
placeholder={t("appPassword")}
|
||||
value={accountPassword}
|
||||
onChange={(event) => setAccountPassword(event.target.value)}
|
||||
/>
|
||||
<button className="primary" type="button" onClick={handleAddAccount}>
|
||||
Speichern
|
||||
{t("mailboxSave")}
|
||||
</button>
|
||||
{accountProvider === "GMAIL" && cleanupAccountId && (
|
||||
<button className="ghost" type="button" onClick={() => startGmailOauth(cleanupAccountId)}>
|
||||
{t("gmailConnect")}
|
||||
</button>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="card">
|
||||
<h3>Cleanup Job starten</h3>
|
||||
<h3>{t("cleanupStart")}</h3>
|
||||
<select value={cleanupAccountId} onChange={(event) => setCleanupAccountId(event.target.value)}>
|
||||
<option value="">Mailbox wählen</option>
|
||||
<option value="">{t("selectMailbox")}</option>
|
||||
{accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.email}
|
||||
@@ -379,7 +447,7 @@ export default function App() {
|
||||
</select>
|
||||
<label className="toggle">
|
||||
<input type="checkbox" checked={dryRun} onChange={(e) => setDryRun(e.target.checked)} />
|
||||
Dry run (keine Änderungen)
|
||||
{t("cleanupDryRun")}
|
||||
</label>
|
||||
<label className="toggle">
|
||||
<input
|
||||
@@ -387,7 +455,7 @@ export default function App() {
|
||||
checked={unsubscribeEnabled}
|
||||
onChange={(e) => setUnsubscribeEnabled(e.target.checked)}
|
||||
/>
|
||||
Unsubscribe aktiv
|
||||
{t("cleanupUnsubscribe")}
|
||||
</label>
|
||||
<label className="toggle">
|
||||
<input
|
||||
@@ -395,7 +463,7 @@ export default function App() {
|
||||
checked={routingEnabled}
|
||||
onChange={(e) => setRoutingEnabled(e.target.checked)}
|
||||
/>
|
||||
Routing aktiv
|
||||
{t("cleanupRouting")}
|
||||
</label>
|
||||
<button className="primary" type="button" onClick={handleStartCleanup}>
|
||||
{t("start")}
|
||||
@@ -403,18 +471,18 @@ export default function App() {
|
||||
</article>
|
||||
|
||||
<article className="card">
|
||||
<h3>Rules</h3>
|
||||
<h3>{t("rulesTitle")}</h3>
|
||||
<input
|
||||
placeholder="Rule Name"
|
||||
placeholder={t("rulesName")}
|
||||
value={ruleName}
|
||||
onChange={(event) => setRuleName(event.target.value)}
|
||||
/>
|
||||
<label className="toggle">
|
||||
<input type="checkbox" checked={ruleEnabled} onChange={(e) => setRuleEnabled(e.target.checked)} />
|
||||
Rule aktiv
|
||||
{t("rulesEnabled")}
|
||||
</label>
|
||||
<div className="rule-block">
|
||||
<h4>Bedingungen</h4>
|
||||
<h4>{t("rulesConditions")}</h4>
|
||||
{conditions.map((condition, idx) => (
|
||||
<div className="row" key={`cond-${idx}`}>
|
||||
<select
|
||||
@@ -427,14 +495,14 @@ export default function App() {
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="LIST_UNSUBSCRIBE">List-Unsubscribe</option>
|
||||
<option value="LIST_ID">List-Id</option>
|
||||
<option value="SUBJECT">Subject</option>
|
||||
<option value="FROM">From</option>
|
||||
<option value="HEADER">Header</option>
|
||||
<option value="LIST_UNSUBSCRIBE">{t("ruleConditionListUnsub")}</option>
|
||||
<option value="LIST_ID">{t("ruleConditionListId")}</option>
|
||||
<option value="SUBJECT">{t("ruleConditionSubject")}</option>
|
||||
<option value="FROM">{t("ruleConditionFrom")}</option>
|
||||
<option value="HEADER">{t("ruleConditionHeader")}</option>
|
||||
</select>
|
||||
<input
|
||||
placeholder="Wert"
|
||||
placeholder={t("value")}
|
||||
value={condition.value}
|
||||
onChange={(event) =>
|
||||
setConditions((prev) =>
|
||||
@@ -446,10 +514,10 @@ export default function App() {
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={addCondition}>+ Bedingung</button>
|
||||
<button type="button" onClick={addCondition}>{t("rulesAddCondition")}</button>
|
||||
</div>
|
||||
<div className="rule-block">
|
||||
<h4>Aktionen</h4>
|
||||
<h4>{t("rulesActions")}</h4>
|
||||
{actions.map((action, idx) => (
|
||||
<div className="row" key={`act-${idx}`}>
|
||||
<select
|
||||
@@ -462,13 +530,13 @@ export default function App() {
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="MOVE">Move</option>
|
||||
<option value="DELETE">Delete</option>
|
||||
<option value="ARCHIVE">Archive</option>
|
||||
<option value="LABEL">Label</option>
|
||||
<option value="MOVE">{t("ruleActionMove")}</option>
|
||||
<option value="DELETE">{t("ruleActionDelete")}</option>
|
||||
<option value="ARCHIVE">{t("ruleActionArchive")}</option>
|
||||
<option value="LABEL">{t("ruleActionLabel")}</option>
|
||||
</select>
|
||||
<input
|
||||
placeholder="Target (z. B. Newsletter)"
|
||||
placeholder={t("targetPlaceholder")}
|
||||
value={action.target ?? ""}
|
||||
onChange={(event) =>
|
||||
setActions((prev) =>
|
||||
@@ -480,41 +548,74 @@ export default function App() {
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={addAction}>+ Aktion</button>
|
||||
<button type="button" onClick={addAction}>{t("rulesAddAction")}</button>
|
||||
</div>
|
||||
<button className="primary" type="button" onClick={handleAddRule}>
|
||||
Rule speichern
|
||||
{t("rulesSave")}
|
||||
</button>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="grid">
|
||||
<article className="card">
|
||||
<h3>Rules Übersicht</h3>
|
||||
<h3>{t("adminMailboxStatus")}</h3>
|
||||
{accounts.map((account) => (
|
||||
<div key={account.id} className="list-item">
|
||||
<div>
|
||||
<strong>{account.email}</strong>
|
||||
<p>
|
||||
{account.provider === "GMAIL"
|
||||
? t("providerGmail")
|
||||
: account.provider === "GMX"
|
||||
? t("providerGmx")
|
||||
: t("providerWebde")}
|
||||
{account.provider === "GMAIL"
|
||||
? ` · ${account.oauthConnected ? t("oauthConnected") : t("oauthMissing")}`
|
||||
: ""}
|
||||
</p>
|
||||
{account.provider === "GMAIL" && (
|
||||
<p className="status-note">
|
||||
{t("adminExpiresAt")}: {account.oauthExpiresAt ? new Date(account.oauthExpiresAt).toLocaleString() : t("oauthStatusUnknown")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{account.provider === "GMAIL" && (
|
||||
<button className="ghost" onClick={() => startGmailOauth(account.id)}>
|
||||
{t("oauthConnect")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="grid">
|
||||
<article className="card">
|
||||
<h3>{t("rulesOverview")}</h3>
|
||||
{rules.map((rule) => (
|
||||
<div key={rule.id} className="list-item">
|
||||
<div>
|
||||
<strong>{rule.name}</strong>
|
||||
<p>{rule.conditions.length} Bedingungen · {rule.actions.length} Aktionen</p>
|
||||
</div>
|
||||
<button className="ghost" onClick={() => handleDeleteRule(rule.id)}>Löschen</button>
|
||||
<button className="ghost" onClick={() => handleDeleteRule(rule.id)}>{t("delete")}</button>
|
||||
</div>
|
||||
))}
|
||||
</article>
|
||||
<article className="card">
|
||||
<h3>Jobs</h3>
|
||||
<h3>{t("jobsTitle")}</h3>
|
||||
{jobs.map((job) => (
|
||||
<div key={job.id} className="list-item">
|
||||
<div>
|
||||
<strong>{job.status}</strong>
|
||||
<strong>{mapJobStatus(job.status)}</strong>
|
||||
<p>{new Date(job.createdAt).toLocaleString()}</p>
|
||||
</div>
|
||||
<button className="ghost" onClick={() => setSelectedJobId(job.id)}>Details</button>
|
||||
<button className="ghost" onClick={() => setSelectedJobId(job.id)}>{t("details")}</button>
|
||||
</div>
|
||||
))}
|
||||
</article>
|
||||
<article className="card">
|
||||
<h3>Job Events</h3>
|
||||
<h3>{t("jobEvents")}</h3>
|
||||
{selectedJobId ? (
|
||||
<div className="events">
|
||||
{events.map((event) => (
|
||||
@@ -525,7 +626,7 @@ export default function App() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p>Kein Job ausgewählt.</p>
|
||||
<p>{t("noJobSelected")}</p>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -26,5 +26,100 @@
|
||||
"login": "Login",
|
||||
"register": "Registrieren",
|
||||
"createAccount": "Account erstellen",
|
||||
"noAccount": "Noch keinen Account?"
|
||||
"noAccount": "Noch keinen Account?",
|
||||
"adminTenants": "Tenants",
|
||||
"adminUsers": "User",
|
||||
"adminAccounts": "Accounts",
|
||||
"adminJobs": "Jobs",
|
||||
"adminExport": "Export",
|
||||
"adminDisable": "Deaktivieren",
|
||||
"adminEnable": "Aktivieren",
|
||||
"adminDelete": "Löschen",
|
||||
"adminMakeUser": "Als USER",
|
||||
"adminMakeAdmin": "Als ADMIN",
|
||||
"adminImpersonate": "Impersonate",
|
||||
"adminResetPassword": "Passwort zurücksetzen",
|
||||
"adminResetTitle": "Passwort zurücksetzen",
|
||||
"adminResetPlaceholder": "Neues Passwort (min 10 Zeichen)",
|
||||
"adminCancel": "Abbrechen",
|
||||
"adminConfirmReset": "Reset",
|
||||
"adminRetry": "Retry",
|
||||
"adminCancelJob": "Cancel",
|
||||
"adminMailboxStatus": "Mailbox Status",
|
||||
"oauthConnected": "OAuth verbunden",
|
||||
"oauthMissing": "OAuth fehlt",
|
||||
"oauthConnect": "OAuth prüfen/verbinden",
|
||||
"gmailConnect": "Gmail OAuth verbinden",
|
||||
"mailboxAdd": "Mailbox hinzufügen",
|
||||
"mailboxSave": "Speichern",
|
||||
"cleanupStart": "Bereinigung starten",
|
||||
"cleanupDryRun": "Dry run (keine Änderungen)",
|
||||
"cleanupUnsubscribe": "Unsubscribe aktiv",
|
||||
"cleanupRouting": "Routing aktiv",
|
||||
"rulesTitle": "Regeln",
|
||||
"rulesName": "Rule Name",
|
||||
"rulesEnabled": "Rule aktiv",
|
||||
"rulesConditions": "Bedingungen",
|
||||
"rulesActions": "Aktionen",
|
||||
"rulesAddCondition": "+ Bedingung",
|
||||
"rulesAddAction": "+ Aktion",
|
||||
"rulesSave": "Rule speichern",
|
||||
"rulesOverview": "Regeln Übersicht",
|
||||
"jobsTitle": "Jobs",
|
||||
"jobEvents": "Job Events",
|
||||
"noJobSelected": "Kein Job ausgewählt.",
|
||||
"tenantName": "Tenant Name",
|
||||
"password": "Passwort",
|
||||
"selectMailbox": "Mailbox wählen",
|
||||
"value": "Wert",
|
||||
"targetPlaceholder": "Target (z. B. Newsletter)",
|
||||
"delete": "Löschen",
|
||||
"details": "Details",
|
||||
"email": "Email",
|
||||
"appPassword": "App Passwort / OAuth Token",
|
||||
"adminDeleteConfirm": "Tenant {{name}} wirklich löschen?"
|
||||
,
|
||||
"countUsers": "{{count}} User",
|
||||
"countAccounts": "{{count}} Accounts",
|
||||
"countJobs": "{{count}} Jobs",
|
||||
"placeholderEmail": "email@example.com",
|
||||
"providerGmail": "Gmail",
|
||||
"providerGmx": "GMX",
|
||||
"providerWebde": "web.de",
|
||||
"selectProvider": "Provider wählen",
|
||||
"statusRunning": "Laufend",
|
||||
"statusQueued": "In Warteschlange",
|
||||
"statusSucceeded": "Erfolgreich",
|
||||
"statusFailed": "Fehlgeschlagen",
|
||||
"statusCanceled": "Abgebrochen",
|
||||
"oauthStatusLabel": "OAuth Status"
|
||||
,
|
||||
"adminExportJson": "Export JSON",
|
||||
"adminExportCsv": "Export CSV",
|
||||
"adminExporting": "Export läuft...",
|
||||
"adminExportDone": "Export bereit",
|
||||
"adminLastConnected": "Zuletzt verbunden",
|
||||
"adminExpiresAt": "Läuft ab",
|
||||
"oauthStatusUnknown": "Unbekannt",
|
||||
"adminTenantExport": "Tenant Export",
|
||||
"adminExportHint": "Export für DSGVO-Anfragen. Format und Scope wählen.",
|
||||
"adminExportScope": "Umfang",
|
||||
"adminExportAll": "Alle Daten",
|
||||
"adminExportUsers": "Nur User",
|
||||
"adminExportAccounts": "Nur Accounts",
|
||||
"adminExportJobs": "Nur Jobs",
|
||||
"adminExportRules": "Nur Regeln",
|
||||
"adminExportStart": "Export starten",
|
||||
"badgeConnected": "Verbunden",
|
||||
"badgeMissing": "Fehlt",
|
||||
"statusLabel": "Status",
|
||||
"ruleConditionHeader": "Header",
|
||||
"ruleConditionSubject": "Subject",
|
||||
"ruleConditionFrom": "From",
|
||||
"ruleConditionListUnsub": "List-Unsubscribe",
|
||||
"ruleConditionListId": "List-Id",
|
||||
"ruleActionMove": "Move",
|
||||
"ruleActionDelete": "Delete",
|
||||
"ruleActionArchive": "Archive",
|
||||
"ruleActionLabel": "Label"
|
||||
}
|
||||
|
||||
@@ -26,5 +26,100 @@
|
||||
"login": "Login",
|
||||
"register": "Register",
|
||||
"createAccount": "Create account",
|
||||
"noAccount": "No account yet?"
|
||||
"noAccount": "No account yet?",
|
||||
"adminTenants": "Tenants",
|
||||
"adminUsers": "Users",
|
||||
"adminAccounts": "Accounts",
|
||||
"adminJobs": "Jobs",
|
||||
"adminExport": "Export",
|
||||
"adminDisable": "Disable",
|
||||
"adminEnable": "Enable",
|
||||
"adminDelete": "Delete",
|
||||
"adminMakeUser": "Make USER",
|
||||
"adminMakeAdmin": "Make ADMIN",
|
||||
"adminImpersonate": "Impersonate",
|
||||
"adminResetPassword": "Reset password",
|
||||
"adminResetTitle": "Reset password",
|
||||
"adminResetPlaceholder": "New password (min 10 characters)",
|
||||
"adminCancel": "Cancel",
|
||||
"adminConfirmReset": "Reset",
|
||||
"adminRetry": "Retry",
|
||||
"adminCancelJob": "Cancel",
|
||||
"adminMailboxStatus": "Mailbox status",
|
||||
"oauthConnected": "OAuth connected",
|
||||
"oauthMissing": "OAuth missing",
|
||||
"oauthConnect": "Check/connect OAuth",
|
||||
"gmailConnect": "Connect Gmail OAuth",
|
||||
"mailboxAdd": "Add mailbox",
|
||||
"mailboxSave": "Save",
|
||||
"cleanupStart": "Start cleanup",
|
||||
"cleanupDryRun": "Dry run (no changes)",
|
||||
"cleanupUnsubscribe": "Unsubscribe enabled",
|
||||
"cleanupRouting": "Routing enabled",
|
||||
"rulesTitle": "Rules",
|
||||
"rulesName": "Rule name",
|
||||
"rulesEnabled": "Rule enabled",
|
||||
"rulesConditions": "Conditions",
|
||||
"rulesActions": "Actions",
|
||||
"rulesAddCondition": "+ Add condition",
|
||||
"rulesAddAction": "+ Add action",
|
||||
"rulesSave": "Save rule",
|
||||
"rulesOverview": "Rules overview",
|
||||
"jobsTitle": "Jobs",
|
||||
"jobEvents": "Job events",
|
||||
"noJobSelected": "No job selected.",
|
||||
"tenantName": "Tenant name",
|
||||
"password": "Password",
|
||||
"selectMailbox": "Select mailbox",
|
||||
"value": "Value",
|
||||
"targetPlaceholder": "Target (e.g. Newsletter)",
|
||||
"delete": "Delete",
|
||||
"details": "Details",
|
||||
"email": "Email",
|
||||
"appPassword": "App password / OAuth token",
|
||||
"adminDeleteConfirm": "Delete tenant {{name}}?"
|
||||
,
|
||||
"countUsers": "{{count}} users",
|
||||
"countAccounts": "{{count}} accounts",
|
||||
"countJobs": "{{count}} jobs",
|
||||
"placeholderEmail": "email@example.com",
|
||||
"providerGmail": "Gmail",
|
||||
"providerGmx": "GMX",
|
||||
"providerWebde": "web.de",
|
||||
"selectProvider": "Select provider",
|
||||
"statusRunning": "Running",
|
||||
"statusQueued": "Queued",
|
||||
"statusSucceeded": "Succeeded",
|
||||
"statusFailed": "Failed",
|
||||
"statusCanceled": "Canceled",
|
||||
"oauthStatusLabel": "OAuth status"
|
||||
,
|
||||
"adminExportJson": "Export JSON",
|
||||
"adminExportCsv": "Export CSV",
|
||||
"adminExporting": "Exporting...",
|
||||
"adminExportDone": "Export ready",
|
||||
"adminLastConnected": "Last connected",
|
||||
"adminExpiresAt": "Expires",
|
||||
"oauthStatusUnknown": "Unknown",
|
||||
"adminTenantExport": "Tenant export",
|
||||
"adminExportHint": "Export tenant data for GDPR requests. Choose format and scope.",
|
||||
"adminExportScope": "Scope",
|
||||
"adminExportAll": "All data",
|
||||
"adminExportUsers": "Users only",
|
||||
"adminExportAccounts": "Accounts only",
|
||||
"adminExportJobs": "Jobs only",
|
||||
"adminExportRules": "Rules only",
|
||||
"adminExportStart": "Start export",
|
||||
"badgeConnected": "Connected",
|
||||
"badgeMissing": "Missing",
|
||||
"statusLabel": "Status",
|
||||
"ruleConditionHeader": "Header",
|
||||
"ruleConditionSubject": "Subject",
|
||||
"ruleConditionFrom": "From",
|
||||
"ruleConditionListUnsub": "List-Unsubscribe",
|
||||
"ruleConditionListId": "List-Id",
|
||||
"ruleActionMove": "Move",
|
||||
"ruleActionDelete": "Delete",
|
||||
"ruleActionArchive": "Archive",
|
||||
"ruleActionLabel": "Label"
|
||||
}
|
||||
|
||||
@@ -86,6 +86,29 @@ select {
|
||||
background: rgba(232, 112, 42, 0.2);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
background: rgba(11, 110, 107, 0.16);
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.status-badge.missing {
|
||||
background: rgba(232, 112, 42, 0.2);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -133,6 +156,39 @@ select {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inline-actions span {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.export-panel {
|
||||
background: rgba(17, 16, 16, 0.04);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(17, 16, 16, 0.4);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--card);
|
||||
padding: 24px;
|
||||
border-radius: 20px;
|
||||
width: min(420px, 90vw);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
|
||||
Reference in New Issue
Block a user