826 lines
28 KiB
TypeScript
826 lines
28 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");
|
|
};
|
|
|
|
if (!isAuthenticated) {
|
|
return (
|
|
<div className="app auth">
|
|
<header className="topbar">
|
|
<div>
|
|
<p className="badge">v0.1</p>
|
|
<h1>{t("appName")}</h1>
|
|
<p className="tagline">{t("tagline")}</p>
|
|
</div>
|
|
<div className="lang-compact" aria-label={t("language")}>
|
|
<div className="lang-buttons">
|
|
{languages.map((lang) => (
|
|
<button
|
|
key={lang.code}
|
|
type="button"
|
|
className={activeLang === lang.code ? "active" : ""}
|
|
onClick={() => switchLanguage(lang.code)}
|
|
>
|
|
{lang.code.toUpperCase()}
|
|
</button>
|
|
))}
|
|
</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>{t("appName")}</h1>
|
|
<p className="tagline">{tenant?.name ?? t("tenantFallback")}</p>
|
|
</div>
|
|
<div className="lang-compact">
|
|
<span className="user-label">{user?.email ?? ""}</span>
|
|
<div className="lang-buttons">
|
|
{languages.map((lang) => (
|
|
<button
|
|
key={lang.code}
|
|
type="button"
|
|
className={activeLang === lang.code ? "active" : ""}
|
|
onClick={() => switchLanguage(lang.code)}
|
|
>
|
|
{lang.code.toUpperCase()}
|
|
</button>
|
|
))}
|
|
<button type="button" onClick={handleLogout}>{t("logout")}</button>
|
|
</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}>
|
|
{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" && (
|
|
<div className="admin-switch">
|
|
<button
|
|
className="ghost"
|
|
type="button"
|
|
onClick={() => setShowAdmin((prev) => !prev)}
|
|
>
|
|
{showAdmin ? t("userWorkspace") : t("adminConsole")}
|
|
</button>
|
|
<span className="status-badge">{t("admin")}</span>
|
|
</div>
|
|
)}
|
|
|
|
{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}>
|
|
{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>
|
|
);
|
|
}
|