Admin UI abtrennen + google settings in gui + UI enhancement

This commit is contained in:
2026-01-22 19:59:39 +01:00
parent e280e4eadb
commit 0b53e47d4b
29 changed files with 2365 additions and 303 deletions

View File

@@ -2,6 +2,7 @@ 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" },
@@ -47,9 +48,15 @@ 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 [authMode, setAuthMode] = useState<"login" | "register">("login");
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("");
@@ -58,12 +65,15 @@ export default function App() {
const [accounts, setAccounts] = useState<Account[]>([]);
const [rules, setRules] = useState<Rule[]>([]);
const [jobs, setJobs] = useState<Job[]>([]);
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
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);
@@ -82,9 +92,28 @@ export default function App() {
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);
@@ -119,12 +148,32 @@ export default function App() {
useEffect(() => {
if (!token) return;
loadInitial(token).catch(() => {
setToken("");
localStorage.removeItem("token");
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(() => {
@@ -156,91 +205,117 @@ export default function App() {
}, [selectedJobId, token]);
const handleAuth = async () => {
if (authMode === "login") {
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/login",
"/auth/register",
{
method: "POST",
body: JSON.stringify({ email: authEmail, password: authPassword })
body: JSON.stringify({ tenantName, email: authEmail, password: authPassword })
}
);
localStorage.setItem("token", result.token);
setToken(result.token);
return;
pushToast(t("toastRegisterSuccess"), "success");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
const result = await apiFetch(
"/auth/register",
{
method: "POST",
body: JSON.stringify({ tenantName, email: authEmail, password: authPassword })
}
);
localStorage.setItem("token", result.token);
setToken(result.token);
};
const handleAddAccount = async () => {
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("");
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 () => {
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 }]);
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) => {
await apiFetch(`/rules/${ruleId}`, { method: "DELETE" }, token);
setRules((prev) => prev.filter((rule) => rule.id !== ruleId));
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 () => {
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([]);
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 = () => {
@@ -248,6 +323,7 @@ export default function App() {
localStorage.removeItem("token");
setUser(null);
setTenant(null);
pushToast(t("toastLoggedOut"), "info");
};
const addCondition = () => setConditions((prev) => [...prev, { ...defaultCondition }]);
@@ -276,16 +352,26 @@ export default function App() {
};
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;
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">
@@ -295,8 +381,7 @@ export default function App() {
<h1>{t("appName")}</h1>
<p className="tagline">{t("tagline")}</p>
</div>
<div className="lang">
<span>{t("language")}</span>
<div className="lang-compact" aria-label={t("language")}>
<div className="lang-buttons">
{languages.map((lang) => (
<button
@@ -305,7 +390,7 @@ export default function App() {
className={activeLang === lang.code ? "active" : ""}
onClick={() => switchLanguage(lang.code)}
>
{lang.label}
{lang.code.toUpperCase()}
</button>
))}
</div>
@@ -358,8 +443,8 @@ export default function App() {
<h1>{t("appName")}</h1>
<p className="tagline">{tenant?.name ?? t("tenantFallback")}</p>
</div>
<div className="lang">
<span>{user?.email ?? ""}</span>
<div className="lang-compact">
<span className="user-label">{user?.email ?? ""}</span>
<div className="lang-buttons">
{languages.map((lang) => (
<button
@@ -368,7 +453,7 @@ export default function App() {
className={activeLang === lang.code ? "active" : ""}
onClick={() => switchLanguage(lang.code)}
>
{lang.label}
{lang.code.toUpperCase()}
</button>
))}
<button type="button" onClick={handleLogout}>{t("logout")}</button>
@@ -413,9 +498,42 @@ export default function App() {
</div>
</section>
{user?.role === "ADMIN" && <AdminPanel token={token} onImpersonate={handleImpersonate} />}
{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>
)}
<section className="grid">
{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
@@ -433,14 +551,24 @@ export default function App() {
value={accountPassword}
onChange={(event) => setAccountPassword(event.target.value)}
/>
<button className="primary" type="button" onClick={handleAddAccount}>
{t("mailboxSave")}
</button>
{accountProvider === "GMAIL" && cleanupAccountId && (
<button className="ghost" type="button" onClick={() => startGmailOauth(cleanupAccountId)}>
{t("gmailConnect")}
<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">
@@ -453,29 +581,33 @@ export default function App() {
</option>
))}
</select>
<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>
<button className="primary" type="button" onClick={handleStartCleanup}>
{t("start")}
</button>
<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">
@@ -485,10 +617,12 @@ export default function App() {
value={ruleName}
onChange={(event) => setRuleName(event.target.value)}
/>
<label className="toggle">
<input type="checkbox" checked={ruleEnabled} onChange={(e) => setRuleEnabled(e.target.checked)} />
{t("rulesEnabled")}
</label>
<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) => (
@@ -522,7 +656,7 @@ export default function App() {
/>
</div>
))}
<button type="button" onClick={addCondition}>{t("rulesAddCondition")}</button>
<button className="add-button" type="button" onClick={addCondition}>{t("rulesAddCondition")}</button>
</div>
<div className="rule-block">
<h4>{t("rulesActions")}</h4>
@@ -556,15 +690,19 @@ export default function App() {
/>
</div>
))}
<button type="button" onClick={addAction}>{t("rulesAddAction")}</button>
<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>
<button className="primary" type="button" onClick={handleAddRule}>
{t("rulesSave")}
</button>
</article>
</section>
)}
<section className="grid">
{!showAdmin && (
<section className="grid">
<article className="card">
<h3>{t("adminMailboxStatus")}</h3>
{accounts.map((account) => (
@@ -613,8 +751,10 @@ export default function App() {
))}
</article>
</section>
)}
<section className="grid">
{!showAdmin && (
<section className="grid">
<article className="card">
<h3>{t("rulesOverview")}</h3>
{rules.map((rule) => (
@@ -658,7 +798,28 @@ export default function App() {
)}
</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>
);
}