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

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

View File

@@ -0,0 +1,3 @@
# `@rollup/rollup-linux-x64-musl`
This is the **x86_64-unknown-linux-musl** binary for `rollup`

View 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"
}

Binary file not shown.

View File

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

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>

View File

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

View File

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

View File

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