Admin UI abtrennen + google settings in gui + UI enhancement
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user