Files
simple-mail-cleaner/frontend/src/App.tsx
2026-01-22 20:15:38 +01:00

852 lines
29 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { apiFetch, createEventSource } from "./api";
import AdminPanel from "./admin";
import { useToast } from "./toast";
const languages = [
{ code: "de", label: "Deutsch" },
{ code: "en", label: "English" }
];
type Account = {
id: string;
email: string;
provider: string;
oauthConnected?: boolean;
oauthExpiresAt?: string | null;
oauthHealthy?: boolean;
oauthError?: { code: string; message: string };
};
type Rule = {
id: string;
name: string;
enabled: boolean;
conditions: { type: string; value: string }[];
actions: { type: string; target?: string | null }[];
};
type Job = {
id: string;
status: string;
createdAt: string;
mailboxAccountId: string;
dryRun: boolean;
};
type JobEvent = {
id: string;
level: string;
message: string;
progress?: number | null;
createdAt: string;
};
const defaultCondition = { type: "LIST_UNSUBSCRIBE", value: "" };
const defaultAction = { type: "MOVE", target: "Newsletter" };
export default function App() {
const { t, i18n } = useTranslation();
const { pushToast } = useToast();
const [activeLang, setActiveLang] = useState(i18n.language);
const [token, setToken] = useState(localStorage.getItem("token") ?? "");
const [showAdmin, setShowAdmin] = useState(
localStorage.getItem("ui.showAdmin") === "true"
);
const [authMode, setAuthMode] = useState<"login" | "register">(
(localStorage.getItem("ui.authMode") as "login" | "register") ?? "login"
);
const [authEmail, setAuthEmail] = useState("");
const [authPassword, setAuthPassword] = useState("");
const [tenantName, setTenantName] = useState("");
const [user, setUser] = useState<{ email: string; role?: string } | null>(null);
const [tenant, setTenant] = useState<{ name: string } | null>(null);
const [accounts, setAccounts] = useState<Account[]>([]);
const [rules, setRules] = useState<Rule[]>([]);
const [jobs, setJobs] = useState<Job[]>([]);
const [selectedJobId, setSelectedJobId] = useState<string | null>(
localStorage.getItem("ui.selectedJobId")
);
const [events, setEvents] = useState<JobEvent[]>([]);
const [accountEmail, setAccountEmail] = useState("");
const [accountProvider, setAccountProvider] = useState("GMAIL");
const [accountPassword, setAccountPassword] = useState("");
const [showProviderHelp, setShowProviderHelp] = useState(false);
const [ruleName, setRuleName] = useState("");
const [ruleEnabled, setRuleEnabled] = useState(true);
const [conditions, setConditions] = useState([{ ...defaultCondition }]);
const [actions, setActions] = useState([{ ...defaultAction }]);
const [cleanupAccountId, setCleanupAccountId] = useState("");
const [dryRun, setDryRun] = useState(true);
const [unsubscribeEnabled, setUnsubscribeEnabled] = useState(true);
const [routingEnabled, setRoutingEnabled] = useState(true);
const switchLanguage = (code: string) => {
i18n.changeLanguage(code);
setActiveLang(code);
};
const isAuthenticated = useMemo(() => Boolean(token), [token]);
const getErrorMessage = (err: unknown) => {
if (err instanceof Error) {
try {
const parsed = JSON.parse(err.message) as { message?: string };
if (parsed?.message) return parsed.message;
} catch {
// ignore parsing
}
return err.message;
}
return t("toastGenericError");
};
const loadInitial = async (authToken: string) => {
const me = await apiFetch("/tenants/me", {}, authToken);
setUser(me.user);
if (me.user?.role === "ADMIN") {
const stored = localStorage.getItem("ui.showAdmin");
if (stored === null) {
setShowAdmin(false);
}
}
setTenant(me.tenant);
const accountsData = await apiFetch("/mail/accounts", {}, authToken);
const enriched = await Promise.all((accountsData.accounts ?? []).map(async (account: Account) => {
if (account.provider === "GMAIL") {
try {
const status = await apiFetch(`/oauth/gmail/ping/${account.id}`, {}, authToken);
return {
...account,
oauthConnected: status.connected,
oauthHealthy: status.healthy,
oauthExpiresAt: status.expiresAt,
oauthError: status.error
};
} catch {
return { ...account, oauthConnected: false, oauthHealthy: false };
}
}
return account;
}));
setAccounts(enriched);
if (!cleanupAccountId && accountsData.accounts?.length) {
setCleanupAccountId(accountsData.accounts[0].id);
}
const rulesData = await apiFetch("/rules", {}, authToken);
setRules(rulesData.rules ?? []);
const jobsData = await apiFetch("/jobs", {}, authToken);
setJobs(jobsData.jobs ?? []);
};
useEffect(() => {
if (!token) return;
loadInitial(token).catch((err: unknown) => {
const status = (err as { status?: number }).status;
if (status === 401 || status === 403) {
setToken("");
localStorage.removeItem("token");
pushToast(t("toastSessionExpired"), "info");
}
});
}, [token]);
useEffect(() => {
localStorage.setItem("ui.showAdmin", String(showAdmin));
}, [showAdmin]);
useEffect(() => {
localStorage.setItem("ui.authMode", authMode);
}, [authMode]);
useEffect(() => {
if (selectedJobId) {
localStorage.setItem("ui.selectedJobId", selectedJobId);
} else {
localStorage.removeItem("ui.selectedJobId");
}
}, [selectedJobId]);
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)
.then((data) => setEvents(data.events ?? []))
.catch(() => setEvents([]));
const source = createEventSource(selectedJobId, token);
source.onmessage = (event) => {
const data = JSON.parse(event.data) as JobEvent;
setEvents((prev) => [...prev, data]);
};
return () => source.close();
}, [selectedJobId, token]);
const handleAuth = async () => {
try {
if (authMode === "login") {
const result = await apiFetch(
"/auth/login",
{
method: "POST",
body: JSON.stringify({ email: authEmail, password: authPassword })
}
);
localStorage.setItem("token", result.token);
setToken(result.token);
pushToast(t("toastLoginSuccess"), "success");
return;
}
const result = await apiFetch(
"/auth/register",
{
method: "POST",
body: JSON.stringify({ tenantName, email: authEmail, password: authPassword })
}
);
localStorage.setItem("token", result.token);
setToken(result.token);
pushToast(t("toastRegisterSuccess"), "success");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
};
const handleAddAccount = async () => {
try {
const result = await apiFetch(
"/mail/accounts",
{
method: "POST",
body: JSON.stringify({
email: accountEmail,
provider: accountProvider,
appPassword: accountPassword || undefined
})
},
token
);
setAccounts((prev) => [...prev, result.account]);
setAccountEmail("");
setAccountPassword("");
pushToast(t("toastMailboxAdded"), "success");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
};
const handleAddRule = async () => {
try {
const result = await apiFetch(
"/rules",
{
method: "POST",
body: JSON.stringify({
name: ruleName,
enabled: ruleEnabled,
conditions,
actions
})
},
token
);
setRules((prev) => [...prev, result.rule]);
setRuleName("");
setConditions([{ ...defaultCondition }]);
setActions([{ ...defaultAction }]);
pushToast(t("toastRuleSaved"), "success");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
};
const handleDeleteRule = async (ruleId: string) => {
try {
await apiFetch(`/rules/${ruleId}`, { method: "DELETE" }, token);
setRules((prev) => prev.filter((rule) => rule.id !== ruleId));
pushToast(t("toastRuleDeleted"), "info");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
};
const handleStartCleanup = async () => {
try {
const result = await apiFetch(
"/mail/cleanup",
{
method: "POST",
body: JSON.stringify({
mailboxAccountId: cleanupAccountId,
dryRun,
unsubscribeEnabled,
routingEnabled
})
},
token
);
const jobsData = await apiFetch("/jobs", {}, token);
setJobs(jobsData.jobs ?? []);
setSelectedJobId(result.jobId);
setEvents([]);
pushToast(t("toastCleanupStarted"), "success");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
};
const handleLogout = () => {
setToken("");
localStorage.removeItem("token");
setUser(null);
setTenant(null);
pushToast(t("toastLoggedOut"), "info");
};
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) => {
try {
const result = await apiFetch(
"/oauth/gmail/url",
{ method: "POST", body: JSON.stringify({ accountId }) },
token
);
if (result.url) {
window.location.href = result.url;
}
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
};
const providerHint = () => {
if (accountProvider === "GMAIL") return t("providerHintGmail");
if (accountProvider === "GMX") return t("providerHintGmx");
return t("providerHintWebde");
};
const cleanupDisabled = true;
if (!isAuthenticated) {
return (
<div className="app auth">
<header className="topbar">
<div>
<p className="badge">v0.1</p>
<h1>
<a className="brand-link" href="/">{t("appName")}</a>
</h1>
<p className="tagline">{t("tagline")}</p>
</div>
<div className="lang-compact" aria-label={t("language")}>
<div className="lang-buttons">
<select
className="lang-select"
value={activeLang}
onChange={(event) => switchLanguage(event.target.value)}
aria-label={t("language")}
>
{languages.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.code.toUpperCase()}
</option>
))}
</select>
</div>
</div>
</header>
<main className="auth-panel">
<div className="card auth-card">
<h2>{authMode === "login" ? t("login") : t("register")}</h2>
<p>{t("description")}</p>
{authMode === "register" && (
<input
placeholder={t("tenantName")}
value={tenantName}
onChange={(event) => setTenantName(event.target.value)}
/>
)}
<input
placeholder={t("email")}
value={authEmail}
onChange={(event) => setAuthEmail(event.target.value)}
/>
<input
placeholder={t("password")}
type="password"
value={authPassword}
onChange={(event) => setAuthPassword(event.target.value)}
/>
<button className="primary" type="button" onClick={handleAuth}>
{authMode === "login" ? t("login") : t("createAccount")}
</button>
<button
className="ghost"
type="button"
onClick={() => setAuthMode(authMode === "login" ? "register" : "login")}
>
{authMode === "login" ? t("noAccount") : t("login")}
</button>
</div>
</main>
</div>
);
}
return (
<div className="app">
<header className="topbar">
<div>
<p className="badge">v0.1</p>
<h1>
<a
className="brand-link"
href="/"
onClick={() => setShowAdmin(false)}
>
{t("appName")}
</a>
</h1>
{user?.role === "ADMIN" && (
<p className="tagline">{tenant?.name ?? t("tenantFallback")}</p>
)}
</div>
<div className="lang-compact">
<span className="user-label">{user?.email ?? ""}</span>
<div className="lang-buttons">
{user?.role === "ADMIN" && (
<button
className="ghost admin-toggle"
type="button"
onClick={() => setShowAdmin((prev) => !prev)}
>
{showAdmin ? t("userWorkspace") : t("adminConsole")}
</button>
)}
<button className="ghost logout-button" type="button" onClick={handleLogout}>{t("logout")}</button>
<select
className="lang-select"
value={activeLang}
onChange={(event) => switchLanguage(event.target.value)}
aria-label={t("language")}
>
{languages.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.code.toUpperCase()}
</option>
))}
</select>
</div>
</div>
</header>
<main>
<section className="hero">
<div>
<h2>{t("welcome")}</h2>
<p className="description">{t("description")}</p>
<div className="actions">
<button
className="primary"
type="button"
onClick={handleStartCleanup}
disabled={cleanupDisabled}
title={t("cleanupDisabled")}
>
{t("start")}
</button>
</div>
</div>
<div className="status-card">
<div className="status-header">
<span>{t("progress")}</span>
<strong>{events.at(-1)?.progress ?? 0}%</strong>
</div>
<div className="progress-bar">
<div className="progress" style={{ width: `${events.at(-1)?.progress ?? 0}%` }} />
</div>
<div className="status-grid">
<div>
<p>{t("mailboxes")}</p>
<h3>{accounts.length}</h3>
</div>
<div>
<p>{t("jobs")}</p>
<h3>{jobs.length}</h3>
</div>
<div>
<p>{t("rules")}</p>
<h3>{rules.length}</h3>
</div>
</div>
<p className="status-note">{t("progressNote")}</p>
</div>
</section>
{user?.role === "ADMIN" && showAdmin ? (
<section className="admin-only">
<div className="section-header">
<div>
<h2>{t("adminConsole")}</h2>
<p>{t("adminConsoleHint")}</p>
</div>
</div>
<AdminPanel token={token} onImpersonate={handleImpersonate} />
</section>
) : (
<section className="section-block">
<div className="section-header">
<div>
<h3>{t("userWorkspace")}</h3>
<p>{t("userWorkspaceHint")}</p>
</div>
</div>
</section>
)}
{!showAdmin && (
<section className="grid">
<article className="card">
<h3>{t("mailboxAdd")}</h3>
<input
placeholder={t("placeholderEmail")}
value={accountEmail}
onChange={(event) => setAccountEmail(event.target.value)}
/>
<select value={accountProvider} onChange={(event) => setAccountProvider(event.target.value)}>
<option value="GMAIL">{t("providerGmail")}</option>
<option value="GMX">{t("providerGmx")}</option>
<option value="WEBDE">{t("providerWebde")}</option>
</select>
<input
placeholder={t("appPassword")}
value={accountPassword}
onChange={(event) => setAccountPassword(event.target.value)}
/>
<p className="hint-text">{providerHint()}</p>
<div className="card-actions">
<button
className="ghost"
type="button"
onClick={() => setShowProviderHelp(true)}
>
{t("providerHelp")}
</button>
<button className="primary" type="button" onClick={handleAddAccount}>
{t("mailboxSave")}
</button>
{accountProvider === "GMAIL" && cleanupAccountId && (
<button className="ghost" type="button" onClick={() => startGmailOauth(cleanupAccountId)}>
{t("gmailConnect")}
</button>
)}
</div>
</article>
<article className="card">
<h3>{t("cleanupStart")}</h3>
<select value={cleanupAccountId} onChange={(event) => setCleanupAccountId(event.target.value)}>
<option value="">{t("selectMailbox")}</option>
{accounts.map((account) => (
<option key={account.id} value={account.id}>
{account.email}
</option>
))}
</select>
<div className="toggle-group">
<label className="toggle">
<input type="checkbox" checked={dryRun} onChange={(e) => setDryRun(e.target.checked)} />
{t("cleanupDryRun")}
</label>
<label className="toggle">
<input
type="checkbox"
checked={unsubscribeEnabled}
onChange={(e) => setUnsubscribeEnabled(e.target.checked)}
/>
{t("cleanupUnsubscribe")}
</label>
<label className="toggle">
<input
type="checkbox"
checked={routingEnabled}
onChange={(e) => setRoutingEnabled(e.target.checked)}
/>
{t("cleanupRouting")}
</label>
</div>
<div className="card-actions">
<button
className="primary"
type="button"
onClick={handleStartCleanup}
disabled={cleanupDisabled}
title={t("cleanupDisabled")}
>
{t("start")}
</button>
</div>
</article>
<article className="card">
<h3>{t("rulesTitle")}</h3>
<input
placeholder={t("rulesName")}
value={ruleName}
onChange={(event) => setRuleName(event.target.value)}
/>
<div className="rule-actions">
<label className="toggle">
<input type="checkbox" checked={ruleEnabled} onChange={(e) => setRuleEnabled(e.target.checked)} />
{t("rulesEnabled")}
</label>
</div>
<div className="rule-block">
<h4>{t("rulesConditions")}</h4>
{conditions.map((condition, idx) => (
<div className="row" key={`cond-${idx}`}>
<select
value={condition.type}
onChange={(event) =>
setConditions((prev) =>
prev.map((item, index) =>
index === idx ? { ...item, type: event.target.value } : item
)
)
}
>
<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={t("value")}
value={condition.value}
onChange={(event) =>
setConditions((prev) =>
prev.map((item, index) =>
index === idx ? { ...item, value: event.target.value } : item
)
)
}
/>
</div>
))}
<button className="add-button" type="button" onClick={addCondition}>{t("rulesAddCondition")}</button>
</div>
<div className="rule-block">
<h4>{t("rulesActions")}</h4>
{actions.map((action, idx) => (
<div className="row" key={`act-${idx}`}>
<select
value={action.type}
onChange={(event) =>
setActions((prev) =>
prev.map((item, index) =>
index === idx ? { ...item, type: event.target.value } : item
)
)
}
>
<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={t("targetPlaceholder")}
value={action.target ?? ""}
onChange={(event) =>
setActions((prev) =>
prev.map((item, index) =>
index === idx ? { ...item, target: event.target.value } : item
)
)
}
/>
</div>
))}
<button className="add-button" type="button" onClick={addAction}>{t("rulesAddAction")}</button>
</div>
<div className="card-actions">
<button className="primary" type="button" onClick={handleAddRule}>
{t("rulesSave")}
</button>
</div>
</article>
</section>
)}
{!showAdmin && (
<section className="grid">
<article className="card">
<h3>{t("adminMailboxStatus")}</h3>
{accounts.map((account) => (
<div key={account.id} className="list-item">
<div>
<div className="badge">
<strong>{account.email}</strong>
{account.provider === "GMAIL" && (
<span className={`status-badge ${account.oauthConnected ? "" : "missing"}`}>
{account.oauthConnected ? t("badgeConnected") : t("badgeMissing")}
</span>
)}
</div>
<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("statusLabel")}: {account.oauthHealthy ? t("badgeHealthy") : t("badgeUnhealthy")} ·{" "}
{t("adminExpiresAt")}: {account.oauthExpiresAt ? new Date(account.oauthExpiresAt).toLocaleString() : t("oauthStatusUnknown")}
</p>
)}
{account.provider === "GMAIL" && account.oauthError && (
<p className="status-note">
{account.oauthError.code === "invalid_grant"
? t("oauthErrorInvalidGrant")
: account.oauthError.code === "token_expired"
? t("oauthErrorExpired")
: t("oauthErrorUnknown")}
</p>
)}
</div>
{account.provider === "GMAIL" && (
<button className="ghost" onClick={() => startGmailOauth(account.id)}>
{t("oauthConnect")}
</button>
)}
</div>
))}
</article>
</section>
)}
{!showAdmin && (
<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>
{t("ruleConditionsCount", { count: rule.conditions.length })} ·{" "}
{t("ruleActionsCount", { count: rule.actions.length })}
</p>
</div>
<button className="ghost" onClick={() => handleDeleteRule(rule.id)}>{t("delete")}</button>
</div>
))}
</article>
<article className="card">
<h3>{t("jobsTitle")}</h3>
{jobs.map((job) => (
<div key={job.id} className="list-item">
<div>
<strong>{mapJobStatus(job.status)}</strong>
<p>{new Date(job.createdAt).toLocaleString()}</p>
</div>
<button className="ghost" onClick={() => setSelectedJobId(job.id)}>{t("details")}</button>
</div>
))}
</article>
<article className="card">
<h3>{t("jobEvents")}</h3>
{selectedJobId ? (
<div className="events">
{events.map((event) => (
<div key={event.id} className={`event ${event.level}`}>
<span>{event.progress ?? "-"}%</span>
<p>{event.message}</p>
</div>
))}
</div>
) : (
<p>{t("noJobSelected")}</p>
)}
</article>
</section>
)}
</main>
{showProviderHelp && (
<div className="modal-backdrop" onClick={() => setShowProviderHelp(false)}>
<div className="modal" onClick={(event) => event.stopPropagation()}>
<div className="modal-header">
<h3>{t("providerHelpTitle")}</h3>
<button className="ghost" type="button" onClick={() => setShowProviderHelp(false)}>
{t("close")}
</button>
</div>
<div className="modal-body">
<h4>{t("providerGmail")}</h4>
<p>{t("providerHelpGmail")}</p>
<h4>{t("providerGmx")}</h4>
<p>{t("providerHelpGmx")}</p>
<h4>{t("providerWebde")}</h4>
<p>{t("providerHelpWebde")}</p>
</div>
</div>
</div>
)}
</div>
);
}