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

2
.env
View File

@@ -35,3 +35,5 @@ SEED_ADMIN_EMAIL=admin@simplemailcleaner.local
SEED_ADMIN_PASSWORD=change-me-now
SEED_TENANT=Default Tenant
SEED_TENANT_ID=seed-tenant
SEED_ENABLED=true
SEED_FORCE_PASSWORD_UPDATE=false

View File

@@ -35,3 +35,5 @@ SEED_ADMIN_EMAIL=admin@simplemailcleaner.local
SEED_ADMIN_PASSWORD=change-me-now
SEED_TENANT=Default Tenant
SEED_TENANT_ID=seed-tenant
SEED_ENABLED=true
SEED_FORCE_PASSWORD_UPDATE=false

File diff suppressed because one or more lines are too long

View File

@@ -257,6 +257,13 @@ exports.Prisma.CleanupJobEventScalarFieldEnum = {
createdAt: 'createdAt'
};
exports.Prisma.AppSettingScalarFieldEnum = {
id: 'id',
key: 'key',
value: 'value',
updatedAt: 'updatedAt'
};
exports.Prisma.SortOrder = {
asc: 'asc',
desc: 'desc'
@@ -324,7 +331,8 @@ exports.Prisma.ModelName = {
RuleAction: 'RuleAction',
CleanupJob: 'CleanupJob',
UnsubscribeAttempt: 'UnsubscribeAttempt',
CleanupJobEvent: 'CleanupJobEvent'
CleanupJobEvent: 'CleanupJobEvent',
AppSetting: 'AppSetting'
};
/**

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
{
"name": "prisma-client-6c67176f7022092b3b8f46007c6286b76456763ea6fcd4c80a580e5070b636e6",
"name": "prisma-client-0dfa452a25e24864bcf3f498cd8b34074b00c8171bdce93526b6a0ab38135aa4",
"main": "index.js",
"types": "index.d.ts",
"browser": "index-browser.js",

View File

@@ -41,6 +41,13 @@ enum RuleConditionType {
LIST_ID
}
enum ExportStatus {
QUEUED
RUNNING
DONE
FAILED
}
model Tenant {
id String @id @default(cuid())
name String
@@ -55,13 +62,6 @@ model Tenant {
jobs CleanupJob[]
}
enum ExportStatus {
QUEUED
RUNNING
DONE
FAILED
}
model ExportJob {
id String @id @default(cuid())
tenantId String
@@ -240,3 +240,10 @@ model CleanupJobEvent {
@@index([jobId])
}
model AppSetting {
id String @id @default(cuid())
key String @unique
value String
updatedAt DateTime @updatedAt
}

View File

@@ -257,6 +257,13 @@ exports.Prisma.CleanupJobEventScalarFieldEnum = {
createdAt: 'createdAt'
};
exports.Prisma.AppSettingScalarFieldEnum = {
id: 'id',
key: 'key',
value: 'value',
updatedAt: 'updatedAt'
};
exports.Prisma.SortOrder = {
asc: 'asc',
desc: 'desc'
@@ -324,7 +331,8 @@ exports.Prisma.ModelName = {
RuleAction: 'RuleAction',
CleanupJob: 'CleanupJob',
UnsubscribeAttempt: 'UnsubscribeAttempt',
CleanupJobEvent: 'CleanupJobEvent'
CleanupJobEvent: 'CleanupJobEvent',
AppSetting: 'AppSetting'
};
/**

View File

@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "AppSetting" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AppSetting_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AppSetting_key_key" ON "AppSetting"("key");

View File

@@ -41,6 +41,13 @@ enum RuleConditionType {
LIST_ID
}
enum ExportStatus {
QUEUED
RUNNING
DONE
FAILED
}
model Tenant {
id String @id @default(cuid())
name String
@@ -55,13 +62,6 @@ model Tenant {
jobs CleanupJob[]
}
enum ExportStatus {
QUEUED
RUNNING
DONE
FAILED
}
model ExportJob {
id String @id @default(cuid())
tenantId String
@@ -240,3 +240,10 @@ model CleanupJobEvent {
@@index([jobId])
}
model AppSetting {
id String @id @default(cuid())
key String @unique
value String
updatedAt DateTime @updatedAt
}

View File

@@ -6,6 +6,7 @@ import { queueCleanupJob, removeQueueJob, queueExportJob } from "../queue/queue.
import { createReadStream } from "node:fs";
import { access, unlink } from "node:fs/promises";
import { cleanupExpiredExports } from "./exportCleanup.js";
import { deleteSetting, listSettings, setSetting } from "./settings.js";
const roleSchema = z.object({
role: z.enum(["USER", "ADMIN"])
@@ -19,9 +20,53 @@ const resetSchema = z.object({
password: z.string().min(10)
});
const settingsSchema = z.object({
settings: z.record(z.string(), z.string().nullable())
});
const allowedSettings = ["google.client_id", "google.client_secret", "google.redirect_uri"] as const;
export async function adminRoutes(app: FastifyInstance) {
app.addHook("preHandler", app.requireAdmin);
app.get("/settings", async () => {
const keys = [...allowedSettings];
const stored = await listSettings(keys);
const envDefaults: Record<string, string | null> = {
"google.client_id": process.env.GOOGLE_CLIENT_ID ?? null,
"google.client_secret": process.env.GOOGLE_CLIENT_SECRET ?? null,
"google.redirect_uri": process.env.GOOGLE_REDIRECT_URI ?? null
};
const settings = keys.reduce<Record<string, { value: string | null; source: "db" | "env" | "unset" }>>((acc, key) => {
const dbValue = stored[key];
if (dbValue !== null && dbValue !== undefined) {
acc[key] = { value: dbValue, source: "db" };
} else if (envDefaults[key]) {
acc[key] = { value: envDefaults[key], source: "env" };
} else {
acc[key] = { value: null, source: "unset" };
}
return acc;
}, {});
return { settings };
});
app.put("/settings", async (request) => {
const input = settingsSchema.parse(request.body);
const entries = Object.entries(input.settings);
for (const [key, value] of entries) {
if (!allowedSettings.includes(key as (typeof allowedSettings)[number])) continue;
if (value === null || value.trim() === "") {
await deleteSetting(key);
} else {
await setSetting(key, value);
}
}
const keys = [...allowedSettings];
const stored = await listSettings(keys);
return { settings: stored };
});
app.get("/tenants", async () => {
const tenants = await prisma.tenant.findMany({
include: { _count: { select: { users: true, mailboxAccounts: true, jobs: true } } },
@@ -156,7 +201,18 @@ export async function adminRoutes(app: FastifyInstance) {
const tenant = await prisma.tenant.findUnique({ where: { id: params.id } });
if (!tenant) return reply.code(404).send({ message: "Tenant not found" });
const exportJobs = await prisma.exportJob.findMany({ where: { tenantId: tenant.id } });
for (const job of exportJobs) {
if (!job.filePath) continue;
try {
await unlink(job.filePath);
} catch {
// ignore missing files
}
}
await prisma.$transaction(async (tx) => {
await tx.exportJob.deleteMany({ where: { tenantId: tenant.id } });
const jobs = await tx.cleanupJob.findMany({ where: { tenantId: tenant.id } });
const jobIds = jobs.map((job) => job.id);
await tx.cleanupJobEvent.deleteMany({ where: { jobId: { in: jobIds } } });

View File

@@ -0,0 +1,27 @@
import { prisma } from "../db.js";
export const getSetting = async (key: string) => {
const setting = await prisma.appSetting.findUnique({ where: { key } });
return setting?.value ?? null;
};
export const setSetting = async (key: string, value: string) => {
return prisma.appSetting.upsert({
where: { key },
update: { value },
create: { key, value }
});
};
export const listSettings = async (keys: string[]) => {
const settings = await prisma.appSetting.findMany({ where: { key: { in: keys } } });
const map = new Map(settings.map((s) => [s.key, s.value]));
return keys.reduce<Record<string, string | null>>((acc, key) => {
acc[key] = map.get(key) ?? null;
return acc;
}, {});
};
export const deleteSetting = async (key: string) => {
await prisma.appSetting.deleteMany({ where: { key } });
};

View File

@@ -9,7 +9,13 @@ const envSchema = z.object({
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
GOOGLE_REDIRECT_URI: z.string().optional(),
TRUST_PROXY: z.coerce.boolean().default(false)
TRUST_PROXY: z.coerce.boolean().default(false),
SEED_ENABLED: z.coerce.boolean().default(true),
SEED_TENANT: z.string().default("Default Tenant"),
SEED_TENANT_ID: z.string().default("seed-tenant"),
SEED_ADMIN_EMAIL: z.string().email().optional(),
SEED_ADMIN_PASSWORD: z.string().min(10).optional(),
SEED_FORCE_PASSWORD_UPDATE: z.coerce.boolean().default(false)
});
export type AppConfig = z.infer<typeof envSchema>;
@@ -23,7 +29,13 @@ const parsed = envSchema.safeParse({
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
GOOGLE_REDIRECT_URI: process.env.GOOGLE_REDIRECT_URI,
TRUST_PROXY: process.env.TRUST_PROXY
TRUST_PROXY: process.env.TRUST_PROXY,
SEED_ENABLED: process.env.SEED_ENABLED,
SEED_TENANT: process.env.SEED_TENANT,
SEED_TENANT_ID: process.env.SEED_TENANT_ID,
SEED_ADMIN_EMAIL: process.env.SEED_ADMIN_EMAIL,
SEED_ADMIN_PASSWORD: process.env.SEED_ADMIN_PASSWORD,
SEED_FORCE_PASSWORD_UPDATE: process.env.SEED_FORCE_PASSWORD_UPDATE
});
if (!parsed.success) {

View File

@@ -2,20 +2,21 @@ import { google } from "googleapis";
import { MailboxAccount } from "@prisma/client";
import { config } from "../config.js";
import { prisma } from "../db.js";
import { getSetting } from "../admin/settings.js";
const getOAuthClient = () => {
if (!config.GOOGLE_CLIENT_ID || !config.GOOGLE_CLIENT_SECRET || !config.GOOGLE_REDIRECT_URI) {
const getOAuthClient = async () => {
const clientId = (await getSetting("google.client_id")) ?? config.GOOGLE_CLIENT_ID;
const clientSecret = (await getSetting("google.client_secret")) ?? config.GOOGLE_CLIENT_SECRET;
const redirectUri = (await getSetting("google.redirect_uri")) ?? config.GOOGLE_REDIRECT_URI;
if (!clientId || !clientSecret || !redirectUri) {
throw new Error("Google OAuth config missing");
}
return new google.auth.OAuth2(
config.GOOGLE_CLIENT_ID,
config.GOOGLE_CLIENT_SECRET,
config.GOOGLE_REDIRECT_URI
);
return new google.auth.OAuth2(clientId, clientSecret, redirectUri);
};
export const getGmailAuthUrl = (state: string) => {
const client = getOAuthClient();
export const getGmailAuthUrl = async (state: string) => {
const client = await getOAuthClient();
return client.generateAuthUrl({
access_type: "offline",
prompt: "consent",
@@ -28,7 +29,7 @@ export const getGmailAuthUrl = (state: string) => {
};
export const exchangeGmailCode = async (code: string) => {
const client = getOAuthClient();
const client = await getOAuthClient();
const { tokens } = await client.getToken(code);
return tokens;
};
@@ -49,7 +50,7 @@ export const gmailClientForAccount = async (account: MailboxAccount) => {
throw new Error("Gmail OAuth not configured");
}
const client = getOAuthClient();
const client = await getOAuthClient();
client.setCredentials({
refresh_token: account.oauthRefreshToken ?? undefined,
access_token: account.oauthAccessToken ?? undefined,

View File

@@ -9,7 +9,7 @@ const urlSchema = z.object({ accountId: z.string() });
export async function oauthRoutes(app: FastifyInstance) {
app.addHook("preHandler", app.authenticate);
app.post("/gmail/url", async (request) => {
app.post("/gmail/url", async (request, reply) => {
const input = urlSchema.parse(request.body);
const account = await prisma.mailboxAccount.findFirst({
where: { id: input.accountId, tenantId: request.user.tenantId, provider: "GMAIL" }
@@ -19,8 +19,12 @@ export async function oauthRoutes(app: FastifyInstance) {
}
const state = `${account.id}:${request.user.tenantId}`;
const url = getGmailAuthUrl(state);
return { url };
try {
const url = await getGmailAuthUrl(state);
return { url };
} catch {
return reply.code(400).send({ message: "Google OAuth config missing" });
}
});
app.get("/gmail/callback", async (request, reply) => {
@@ -37,7 +41,12 @@ export async function oauthRoutes(app: FastifyInstance) {
return reply.code(404).send({ message: "Account not found" });
}
const tokens = await exchangeGmailCode(query.code);
let tokens;
try {
tokens = await exchangeGmailCode(query.code);
} catch {
return reply.code(400).send({ message: "Google OAuth config missing" });
}
await storeGmailTokens(account.id, {
access_token: tokens.access_token ?? undefined,
refresh_token: tokens.refresh_token ?? undefined,

View File

@@ -15,6 +15,7 @@ import { queueRoutes } from "./queue/routes.js";
import { rulesRoutes } from "./rules/routes.js";
import { adminRoutes } from "./admin/routes.js";
import { oauthRoutes } from "./mail/oauthRoutes.js";
import { ensureSeedData } from "./seed.js";
const app = Fastify({
logger: {
@@ -26,7 +27,11 @@ const app = Fastify({
trustProxy: config.TRUST_PROXY
});
await app.register(cors, { origin: true });
await app.register(cors, {
origin: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Authorization", "Content-Type"]
});
await app.register(helmet);
await app.register(jwt, { secret: config.JWT_SECRET });
await app.register(authPlugin);
@@ -48,6 +53,8 @@ await app.register(rulesRoutes, { prefix: "/rules" });
await app.register(adminRoutes, { prefix: "/admin" });
await app.register(oauthRoutes, { prefix: "/oauth" });
await ensureSeedData();
const start = async () => {
try {
await app.listen({ port: config.PORT, host: "0.0.0.0" });

39
backend/src/seed.ts Normal file
View File

@@ -0,0 +1,39 @@
import argon2 from "argon2";
import { prisma } from "./db.js";
import { config } from "./config.js";
export const ensureSeedData = async () => {
if (!config.SEED_ENABLED) return;
if (!config.SEED_ADMIN_EMAIL || !config.SEED_ADMIN_PASSWORD) return;
const existingUser = await prisma.user.findUnique({ where: { email: config.SEED_ADMIN_EMAIL } });
if (existingUser) {
const updates: { role?: "ADMIN"; isActive?: boolean; password?: string } = {};
if (existingUser.role !== "ADMIN") updates.role = "ADMIN";
if (!existingUser.isActive) updates.isActive = true;
if (config.SEED_FORCE_PASSWORD_UPDATE) {
updates.password = await argon2.hash(config.SEED_ADMIN_PASSWORD);
}
if (Object.keys(updates).length) {
await prisma.user.update({ where: { id: existingUser.id }, data: updates });
}
return;
}
const tenant = await prisma.tenant.upsert({
where: { id: config.SEED_TENANT_ID },
update: { name: config.SEED_TENANT, isActive: true },
create: { id: config.SEED_TENANT_ID, name: config.SEED_TENANT, isActive: true }
});
const hashed = await argon2.hash(config.SEED_ADMIN_PASSWORD);
await prisma.user.create({
data: {
tenantId: tenant.id,
email: config.SEED_ADMIN_EMAIL,
password: hashed,
role: "ADMIN",
isActive: true
}
});
};

View File

@@ -42,6 +42,7 @@ services:
depends_on:
- postgres
- redis
command: ["sh", "-c", "npm run prisma:generate && npm run dev"]
ports:
- "${API_PORT:-8000}:${API_PORT:-8000}"
volumes:
@@ -71,7 +72,7 @@ services:
depends_on:
- postgres
- redis
command: ["npm", "run", "worker:dev"]
command: ["sh", "-c", "npm run prisma:generate && npm run worker:dev"]
volumes:
- ./backend:/app

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>
);
}

View File

@@ -3,6 +3,7 @@ import { apiFetch, createEventSourceFor } from "./api";
import { downloadFile } from "./export";
import { downloadExport } from "./exportHistory";
import { useTranslation } from "react-i18next";
import { useToast } from "./toast";
type Tenant = {
id: string;
@@ -39,6 +40,11 @@ type Job = {
mailboxAccount?: { id: string; email: string } | null;
};
type SettingValue = {
value: string | null;
source: "db" | "env" | "unset";
};
type Props = {
token: string;
onImpersonate: (token: string) => void;
@@ -46,11 +52,14 @@ type Props = {
export default function AdminPanel({ token, onImpersonate }: Props) {
const { t } = useTranslation();
const { pushToast } = useToast();
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 [activeTab, setActiveTab] = useState<"tenants" | "users" | "accounts" | "jobs" | "settings">(
(localStorage.getItem("ui.adminTab") as "tenants" | "users" | "accounts" | "jobs" | "settings") ?? "tenants"
);
const [resetUserId, setResetUserId] = useState<string | null>(null);
const [resetPassword, setResetPassword] = useState("");
const [exportTenantId, setExportTenantId] = useState<string | null>(null);
@@ -64,6 +73,14 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
const [userSort, setUserSort] = useState<"recent" | "oldest" | "email">("recent");
const [accountSort, setAccountSort] = useState<"recent" | "oldest" | "email">("recent");
const [jobSort, setJobSort] = useState<"recent" | "oldest" | "status">("recent");
const [settings, setSettings] = useState<Record<string, SettingValue>>({});
const [settingsDraft, setSettingsDraft] = useState({
googleClientId: "",
googleClientSecret: "",
googleRedirectUri: ""
});
const [settingsStatus, setSettingsStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
const [showGoogleSecret, setShowGoogleSecret] = useState(false);
const loadAll = async () => {
const tenantData = await apiFetch("/admin/tenants", {}, token);
@@ -77,59 +94,108 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
const jobsData = await apiFetch("/admin/jobs", {}, token);
setJobs(jobsData.jobs ?? []);
const exportsData = await apiFetch("/admin/exports", {}, token);
setExportHistory(exportsData.exports ?? []);
const exportsData = await apiFetch("/admin/exports", {}, token);
setExportHistory(exportsData.exports ?? []);
try {
await loadSettings();
} catch {
// ignore settings fetch failures in initial load
}
};
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");
};
useEffect(() => {
loadAll().catch(() => undefined);
}, []);
useEffect(() => {
localStorage.setItem("ui.adminTab", activeTab);
}, [activeTab]);
const loadSettings = async () => {
const data = await apiFetch("/admin/settings", {}, token);
const next = data.settings ?? {};
setSettings(next);
setSettingsDraft({
googleClientId: next["google.client_id"]?.value ?? "",
googleClientSecret: next["google.client_secret"]?.value ?? "",
googleRedirectUri: next["google.redirect_uri"]?.value ?? ""
});
};
const toggleTenant = async (tenant: Tenant) => {
const result = await apiFetch(
`/admin/tenants/${tenant.id}`,
{ method: "PUT", body: JSON.stringify({ isActive: !tenant.isActive }) },
token
);
setTenants((prev) => prev.map((item) => (item.id === tenant.id ? result.tenant : item)));
try {
const result = await apiFetch(
`/admin/tenants/${tenant.id}`,
{ method: "PUT", body: JSON.stringify({ isActive: !tenant.isActive }) },
token
);
setTenants((prev) => prev.map((item) => (item.id === tenant.id ? result.tenant : item)));
pushToast(t("toastTenantUpdated"), "success");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
};
const exportTenant = async (tenant: Tenant) => {
setExportTenantId(tenant.id);
setExportStatus("loading");
if (exportFormat === "json") {
const result = await apiFetch(`/admin/tenants/${tenant.id}/export?scope=${exportScope}`, {}, token);
const blob = new Blob([JSON.stringify(result, null, 2)], { type: "application/json" });
downloadFile(blob, `tenant-${tenant.id}.json`);
} else if (exportFormat === "csv") {
await exportTenantCsv(tenant);
return;
} else {
const result = await apiFetch(`/admin/tenants/${tenant.id}/export?format=zip&scope=${exportScope}`, {}, token);
setExportJobId(result.jobId);
setExportHistory((prev) => [{ id: result.jobId, status: "QUEUED" }, ...prev]);
const source = createEventSourceFor(`exports/${result.jobId}`, token);
source.onmessage = async (event) => {
const data = JSON.parse(event.data);
setExportHistory((prev) =>
prev.map((item) => (item.id === data.id ? { ...item, status: data.status, expiresAt: data.expiresAt, progress: data.progress } : item))
);
if (data.status === "DONE") {
const response = await downloadExport(token, result.jobId);
const blob = await response.blob();
downloadFile(blob, `tenant-${tenant.id}.zip`);
setExportStatus("done");
source.close();
setTimeout(() => setExportStatus("idle"), 1500);
} else if (data.status === "FAILED") {
setExportStatus("failed");
source.close();
}
};
return;
try {
setExportTenantId(tenant.id);
setExportStatus("loading");
if (exportFormat === "json") {
const result = await apiFetch(`/admin/tenants/${tenant.id}/export?scope=${exportScope}`, {}, token);
const blob = new Blob([JSON.stringify(result, null, 2)], { type: "application/json" });
downloadFile(blob, `tenant-${tenant.id}.json`);
pushToast(t("toastExportReady"), "success");
} else if (exportFormat === "csv") {
await exportTenantCsv(tenant);
return;
} else {
const result = await apiFetch(`/admin/tenants/${tenant.id}/export?format=zip&scope=${exportScope}`, {}, token);
setExportJobId(result.jobId);
setExportHistory((prev) => [{ id: result.jobId, status: "QUEUED" }, ...prev]);
pushToast(t("toastExportQueued"), "info");
const source = createEventSourceFor(`exports/${result.jobId}`, token);
source.onmessage = async (event) => {
const data = JSON.parse(event.data);
setExportHistory((prev) =>
prev.map((item) => (item.id === data.id ? { ...item, status: data.status, expiresAt: data.expiresAt, progress: data.progress } : item))
);
if (data.status === "DONE") {
const response = await downloadExport(token, result.jobId);
const blob = await response.blob();
downloadFile(blob, `tenant-${tenant.id}.zip`);
setExportStatus("done");
pushToast(t("toastExportReady"), "success");
source.close();
setTimeout(() => setExportStatus("idle"), 1500);
} else if (data.status === "FAILED") {
setExportStatus("failed");
pushToast(t("toastExportFailed"), "error");
source.close();
}
};
return;
}
setExportStatus("done");
setTimeout(() => setExportStatus("idle"), 1500);
} catch (err) {
setExportStatus("failed");
pushToast(getErrorMessage(err), "error");
}
setExportStatus("done");
setTimeout(() => setExportStatus("idle"), 1500);
};
const exportTenantCsv = async (tenant: Tenant) => {
@@ -139,69 +205,113 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
const response = await fetch(`${base}/admin/tenants/${tenant.id}/export?format=csv&scope=${exportScope}`, {
headers: { Authorization: `Bearer ${token}` }
});
if (!response.ok) {
throw new Error(await response.text());
}
const text = await response.text();
const blob = new Blob([text], { type: "text/csv" });
downloadFile(blob, `tenant-${tenant.id}.csv`);
setExportStatus("done");
setTimeout(() => setExportStatus("idle"), 1500);
pushToast(t("toastExportReady"), "success");
};
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));
try {
await apiFetch(`/admin/tenants/${tenant.id}`, { method: "DELETE" }, token);
setTenants((prev) => prev.filter((item) => item.id !== tenant.id));
pushToast(t("toastTenantDeleted"), "info");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
};
const toggleUser = async (user: User) => {
const result = await apiFetch(
`/admin/users/${user.id}`,
{ method: "PUT", body: JSON.stringify({ isActive: !user.isActive }) },
token
);
setUsers((prev) => prev.map((item) => (item.id === user.id ? result.user : item)));
try {
const result = await apiFetch(
`/admin/users/${user.id}`,
{ method: "PUT", body: JSON.stringify({ isActive: !user.isActive }) },
token
);
setUsers((prev) => prev.map((item) => (item.id === user.id ? result.user : item)));
pushToast(t("toastUserUpdated"), "success");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
};
const toggleAccount = async (account: Account) => {
const result = await apiFetch(
`/admin/accounts/${account.id}`,
{ method: "PUT", body: JSON.stringify({ isActive: !account.isActive }) },
token
);
setAccounts((prev) => prev.map((item) => (item.id === account.id ? result.account : item)));
try {
const result = await apiFetch(
`/admin/accounts/${account.id}`,
{ method: "PUT", body: JSON.stringify({ isActive: !account.isActive }) },
token
);
setAccounts((prev) => prev.map((item) => (item.id === account.id ? result.account : item)));
pushToast(t("toastAccountUpdated"), "success");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
};
const setRole = async (user: User, role: "USER" | "ADMIN") => {
const result = await apiFetch(
`/admin/users/${user.id}/role`,
{ method: "PUT", body: JSON.stringify({ role }) },
token
);
setUsers((prev) => prev.map((item) => (item.id === user.id ? result.user : item)));
try {
const result = await apiFetch(
`/admin/users/${user.id}/role`,
{ method: "PUT", body: JSON.stringify({ role }) },
token
);
setUsers((prev) => prev.map((item) => (item.id === user.id ? result.user : item)));
pushToast(t("toastRoleUpdated"), "success");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
};
const impersonate = async (user: User) => {
const result = await apiFetch(`/admin/impersonate/${user.id}`, { method: "POST" }, token);
onImpersonate(result.token);
try {
const result = await apiFetch(`/admin/impersonate/${user.id}`, { method: "POST" }, token);
onImpersonate(result.token);
pushToast(t("toastImpersonate"), "info");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
};
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("");
try {
await apiFetch(`/admin/users/${resetUserId}/reset`, {
method: "POST",
body: JSON.stringify({ password: resetPassword })
}, token);
setResetUserId(null);
setResetPassword("");
pushToast(t("toastPasswordReset"), "success");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
};
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)));
try {
await apiFetch(`/admin/jobs/${job.id}/cancel`, { method: "POST" }, token);
setJobs((prev) => prev.map((item) => (item.id === job.id ? { ...item, status: "CANCELED" } : item)));
pushToast(t("toastJobCanceled"), "info");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
};
const retryJob = async (job: Job) => {
await apiFetch(`/admin/jobs/${job.id}/retry`, { method: "POST" }, token);
loadAll().catch(() => undefined);
try {
await apiFetch(`/admin/jobs/${job.id}/retry`, { method: "POST" }, token);
loadAll().catch(() => undefined);
pushToast(t("toastJobRetry"), "success");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
};
const sortBy = <T,>(items: T[], mode: string, getKey: (item: T) => string) => {
@@ -243,10 +353,37 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
}
};
const saveSettings = async () => {
try {
setSettingsStatus("saving");
await apiFetch(
"/admin/settings",
{
method: "PUT",
body: JSON.stringify({
settings: {
"google.client_id": settingsDraft.googleClientId,
"google.client_secret": settingsDraft.googleClientSecret,
"google.redirect_uri": settingsDraft.googleRedirectUri
}
})
},
token
);
await loadSettings();
setSettingsStatus("saved");
setTimeout(() => setSettingsStatus("idle"), 1500);
pushToast(t("toastSettingsSaved"), "success");
} catch {
setSettingsStatus("error");
pushToast(t("toastSettingsFailed"), "error");
}
};
return (
<section className="admin-panel">
<div className="admin-tabs">
{(["tenants", "users", "accounts", "jobs"] as const).map((tab) => (
{(["tenants", "users", "accounts", "jobs", "settings"] as const).map((tab) => (
<button
key={tab}
className={activeTab === tab ? "active" : ""}
@@ -273,7 +410,7 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
<div className="export-panel">
<h4>{t("adminTenantExport")}</h4>
<p className="status-note">{t("adminExportHint")}</p>
<label className="toggle">
<label className="field-row">
<span>{t("adminExportScope")}</span>
<select value={exportScope} onChange={(event) => setExportScope(event.target.value as typeof exportScope)}>
<option value="all">{t("adminExportAll")}</option>
@@ -283,7 +420,7 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
<option value="rules">{t("adminExportRules")}</option>
</select>
</label>
<label className="toggle">
<label className="field-row">
<span>{t("adminExportFormat")}</span>
<select value={exportFormat} onChange={(event) => setExportFormat(event.target.value as typeof exportFormat)}>
<option value="json">{t("exportFormatJson")}</option>
@@ -344,8 +481,13 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
<button
className="ghost"
onClick={async () => {
await apiFetch("/admin/exports/purge", { method: "POST" }, token);
loadAll().catch(() => undefined);
try {
await apiFetch("/admin/exports/purge", { method: "POST" }, token);
loadAll().catch(() => undefined);
pushToast(t("toastExportPurged"), "info");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
}}
>
{t("adminExportPurge")}
@@ -391,8 +533,13 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
<button
className="ghost"
onClick={async () => {
await apiFetch(`/admin/exports/${item.id}`, { method: "DELETE" }, token);
loadAll().catch(() => undefined);
try {
await apiFetch(`/admin/exports/${item.id}`, { method: "DELETE" }, token);
loadAll().catch(() => undefined);
pushToast(t("toastExportDeleted"), "info");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
}}
>
{t("delete")}
@@ -541,6 +688,56 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
))}
</div>
)}
{activeTab === "settings" && (
<div className="card">
<h3>{t("adminSettings")}</h3>
<p className="status-note">{t("adminSettingsHint")}</p>
<div className="panel">
<h4>{t("adminGoogleSettings")}</h4>
<p className="hint-text">{t("adminGoogleSettingsHelp")}</p>
<label className="field-row">
<span>{t("adminGoogleClientId")}</span>
<input
value={settingsDraft.googleClientId}
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, googleClientId: event.target.value }))}
/>
</label>
<label className="field-row">
<span>{t("adminGoogleClientSecret")}</span>
<input
type={showGoogleSecret ? "text" : "password"}
value={settingsDraft.googleClientSecret}
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, googleClientSecret: event.target.value }))}
/>
</label>
<label className="field-row">
<span>{t("adminGoogleRedirectUri")}</span>
<input
value={settingsDraft.googleRedirectUri}
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, googleRedirectUri: event.target.value }))}
/>
</label>
<div className="inline-actions">
<button className="ghost" onClick={() => setShowGoogleSecret((prev) => !prev)}>
{showGoogleSecret ? t("adminHideSecret") : t("adminShowSecret")}
</button>
<button className="primary" onClick={saveSettings} disabled={settingsStatus === "saving"}>
{settingsStatus === "saving" ? t("adminSaving") : t("adminSaveSettings")}
</button>
{settingsStatus === "saved" && <span className="status-badge">{t("adminSettingsSaved")}</span>}
{settingsStatus === "error" && <span className="status-badge missing">{t("adminSettingsError")}</span>}
</div>
<div className="status-note">
{t("adminSettingsSource", {
id: settings["google.client_id"]?.source ?? "unset",
secret: settings["google.client_secret"]?.source ?? "unset",
redirect: settings["google.redirect_uri"]?.source ?? "unset"
})}
</div>
</div>
</div>
)}
</section>
);
}

View File

@@ -2,7 +2,10 @@ const apiUrl = import.meta.env.VITE_API_URL ?? "http://localhost:8000";
export const apiFetch = async (path: string, options: RequestInit = {}, token?: string) => {
const headers = new Headers(options.headers ?? {});
headers.set("Content-Type", "application/json");
const hasBody = options.body !== undefined && options.body !== null;
if (hasBody && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
@@ -14,7 +17,9 @@ export const apiFetch = async (path: string, options: RequestInit = {}, token?:
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed: ${response.status}`);
const error = new Error(message || `Request failed: ${response.status}`) as Error & { status?: number };
error.status = response.status;
throw error;
}
return response.json();

View File

@@ -8,7 +8,7 @@ i18n.use(initReactI18next).init({
en: { translation: en },
de: { translation: de }
},
lng: "de",
lng: typeof window !== "undefined" && window.navigator.language.toLowerCase().startsWith("de") ? "de" : "en",
fallbackLng: "en",
interpolation: { escapeValue: false }
});

View File

@@ -31,6 +31,7 @@
"adminUsers": "User",
"adminAccounts": "Accounts",
"adminJobs": "Jobs",
"adminSettings": "Einstellungen",
"adminExport": "Export",
"adminDisable": "Deaktivieren",
"adminEnable": "Aktivieren",
@@ -43,6 +44,19 @@
"adminResetPlaceholder": "Neues Passwort (min 10 Zeichen)",
"adminCancel": "Abbrechen",
"adminConfirmReset": "Reset",
"adminSettingsHint": "OAuth-Einstellungen aus der Umgebung überschreiben. Leer lassen, um auf .env zurückzufallen.",
"adminGoogleSettings": "Google OAuth",
"adminGoogleSettingsHelp": "Lege in der Google Cloud Console einen OAuthClient an. Trage die RedirectURL unten als erlaubte WeiterleitungsURL ein. Consent Screen auf „extern“ setzen und Gmail API aktivieren.",
"adminGoogleClientId": "Client-ID",
"adminGoogleClientSecret": "Client-Secret",
"adminGoogleRedirectUri": "Redirect-URL",
"adminSaveSettings": "Einstellungen speichern",
"adminSaving": "Speichert...",
"adminSettingsSaved": "Gespeichert",
"adminSettingsError": "Speichern fehlgeschlagen",
"adminShowSecret": "Secret anzeigen",
"adminHideSecret": "Secret verbergen",
"adminSettingsSource": "Quellen - Client-ID: {{id}}, Secret: {{secret}}, Redirect: {{redirect}}",
"adminRetry": "Retry",
"adminCancelJob": "Cancel",
"adminMailboxStatus": "Mailbox Status",
@@ -63,7 +77,7 @@
"rulesActions": "Aktionen",
"rulesAddCondition": "+ Bedingung",
"rulesAddAction": "+ Aktion",
"rulesSave": "Rule speichern",
"rulesSave": "Regel speichern",
"rulesOverview": "Regeln Übersicht",
"jobsTitle": "Jobs",
"jobEvents": "Job Events",
@@ -83,6 +97,15 @@
"countAccounts": "{{count}} Accounts",
"countJobs": "{{count}} Jobs",
"placeholderEmail": "email@example.com",
"providerHintGmail": "Gmail nutzt OAuth. Du kannst das Passwort leer lassen und per OAuth verbinden.",
"providerHintGmx": "GMX nutzt IMAP. Gib hier dein AppPasswort oder IMAPPasswort ein.",
"providerHintWebde": "web.de nutzt IMAP. Gib hier dein AppPasswort oder IMAPPasswort ein.",
"providerHelp": "Hilfe zur MailboxEinrichtung",
"providerHelpTitle": "MailboxEinrichtung",
"providerHelpGmail": "Erstelle einen Google OAuthClient und verbinde per OAuthButton. Das Passwortfeld ist bei OAuth nicht nötig.",
"providerHelpGmx": "Aktiviere IMAP in den GMXEinstellungen und erstelle ein AppPasswort. Dieses Passwort hier verwenden.",
"providerHelpWebde": "Aktiviere IMAP in den web.deEinstellungen und erstelle ein AppPasswort. Dieses Passwort hier verwenden.",
"close": "Schließen",
"providerGmail": "Gmail",
"providerGmx": "GMX",
"providerWebde": "web.de",
@@ -153,5 +176,34 @@
"exportStatusFailed": "Fehlgeschlagen",
"exportStatusExpired": "Abgelaufen",
"adminExportPurge": "Abgelaufene löschen",
"exportProgress": "Fortschritt {{progress}}%"
"exportProgress": "Fortschritt {{progress}}%",
"adminConsole": "Admin-Konsole",
"adminConsoleHint": "Globale Steuerung für Tenants, User, Accounts, Exporte und Jobs.",
"userWorkspace": "User-Bereich",
"userWorkspaceHint": "Alles darunter betrifft nur dein eigenes Postfach und deine Regeln.",
"toastGenericError": "Etwas ist schiefgelaufen.",
"toastSessionExpired": "Session abgelaufen. Bitte erneut einloggen.",
"toastLoginSuccess": "Erfolgreich eingeloggt.",
"toastRegisterSuccess": "Account erfolgreich erstellt.",
"toastMailboxAdded": "Mailbox hinzugefügt.",
"toastRuleSaved": "Regel gespeichert.",
"toastRuleDeleted": "Regel gelöscht.",
"toastCleanupStarted": "Bereinigung gestartet.",
"toastLoggedOut": "Ausgeloggt.",
"toastExportQueued": "Export in Warteschlange.",
"toastExportReady": "Export bereit.",
"toastExportFailed": "Export fehlgeschlagen.",
"toastExportPurged": "Abgelaufene Exporte entfernt.",
"toastExportDeleted": "Export gelöscht.",
"toastTenantUpdated": "Tenant aktualisiert.",
"toastTenantDeleted": "Tenant gelöscht.",
"toastUserUpdated": "User aktualisiert.",
"toastAccountUpdated": "Account aktualisiert.",
"toastRoleUpdated": "Rolle aktualisiert.",
"toastImpersonate": "Impersonation gestartet.",
"toastPasswordReset": "Passwort zurückgesetzt.",
"toastJobCanceled": "Job abgebrochen.",
"toastJobRetry": "Job neu gestartet.",
"toastSettingsSaved": "Einstellungen gespeichert.",
"toastSettingsFailed": "Einstellungen konnten nicht gespeichert werden."
}

View File

@@ -31,6 +31,7 @@
"adminUsers": "Users",
"adminAccounts": "Accounts",
"adminJobs": "Jobs",
"adminSettings": "Settings",
"adminExport": "Export",
"adminDisable": "Disable",
"adminEnable": "Enable",
@@ -43,6 +44,19 @@
"adminResetPlaceholder": "New password (min 10 characters)",
"adminCancel": "Cancel",
"adminConfirmReset": "Reset",
"adminSettingsHint": "Override OAuth settings stored in the environment. Leave empty to fall back to .env.",
"adminGoogleSettings": "Google OAuth",
"adminGoogleSettingsHelp": "Create an OAuth client in Google Cloud Console. Add the Redirect URL below as an authorized redirect URI. Use OAuth consent screen in external mode and enable Gmail API.",
"adminGoogleClientId": "Client ID",
"adminGoogleClientSecret": "Client secret",
"adminGoogleRedirectUri": "Redirect URL",
"adminSaveSettings": "Save settings",
"adminSaving": "Saving...",
"adminSettingsSaved": "Saved",
"adminSettingsError": "Save failed",
"adminShowSecret": "Show secret",
"adminHideSecret": "Hide secret",
"adminSettingsSource": "Sources - Client ID: {{id}}, Secret: {{secret}}, Redirect: {{redirect}}",
"adminRetry": "Retry",
"adminCancelJob": "Cancel",
"adminMailboxStatus": "Mailbox status",
@@ -83,6 +97,15 @@
"countAccounts": "{{count}} accounts",
"countJobs": "{{count}} jobs",
"placeholderEmail": "email@example.com",
"providerHintGmail": "Gmail uses OAuth. You can leave the password empty and connect via the OAuth button.",
"providerHintGmx": "GMX uses IMAP. Enter your app password or IMAP password for this mailbox.",
"providerHintWebde": "web.de uses IMAP. Enter your app password or IMAP password for this mailbox.",
"providerHelp": "Help for mailbox setup",
"providerHelpTitle": "Mailbox setup help",
"providerHelpGmail": "Create a Google OAuth Client and connect via the OAuth button. The password field is not required when OAuth is used.",
"providerHelpGmx": "Enable IMAP in your GMX settings and create an app password. Use that password here.",
"providerHelpWebde": "Enable IMAP in your web.de account settings and create an app password. Use that password here.",
"close": "Close",
"providerGmail": "Gmail",
"providerGmx": "GMX",
"providerWebde": "web.de",
@@ -153,5 +176,34 @@
"exportStatusFailed": "Failed",
"exportStatusExpired": "Expired",
"adminExportPurge": "Purge expired",
"exportProgress": "Progress {{progress}}%"
"exportProgress": "Progress {{progress}}%",
"adminConsole": "Admin console",
"adminConsoleHint": "Global controls for tenants, users, accounts, exports, and jobs.",
"userWorkspace": "User workspace",
"userWorkspaceHint": "Everything below affects only your own mailbox and rules.",
"toastGenericError": "Something went wrong.",
"toastSessionExpired": "Session expired. Please log in again.",
"toastLoginSuccess": "Logged in successfully.",
"toastRegisterSuccess": "Account created successfully.",
"toastMailboxAdded": "Mailbox added.",
"toastRuleSaved": "Rule saved.",
"toastRuleDeleted": "Rule deleted.",
"toastCleanupStarted": "Cleanup job started.",
"toastLoggedOut": "Logged out.",
"toastExportQueued": "Export queued.",
"toastExportReady": "Export ready.",
"toastExportFailed": "Export failed.",
"toastExportPurged": "Expired exports purged.",
"toastExportDeleted": "Export deleted.",
"toastTenantUpdated": "Tenant updated.",
"toastTenantDeleted": "Tenant deleted.",
"toastUserUpdated": "User updated.",
"toastAccountUpdated": "Account updated.",
"toastRoleUpdated": "Role updated.",
"toastImpersonate": "Impersonation started.",
"toastPasswordReset": "Password reset.",
"toastJobCanceled": "Job canceled.",
"toastJobRetry": "Job retried.",
"toastSettingsSaved": "Settings saved.",
"toastSettingsFailed": "Settings save failed."
}

View File

@@ -3,9 +3,12 @@ import ReactDOM from "react-dom/client";
import "./i18n";
import "./styles.css";
import App from "./App";
import { ToastProvider } from "./toast";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
<ToastProvider>
<App />
</ToastProvider>
</React.StrictMode>
);

View File

@@ -2,13 +2,13 @@
:root {
color-scheme: light;
--bg: #f4f6f3;
--bg-accent: #eef1ea;
--ink: #111413;
--muted: #5b615b;
--primary: #0f766e;
--primary-strong: #0b5f59;
--accent: #e2b644;
--bg: #f3f6fb;
--bg-accent: #e9f0ff;
--ink: #101827;
--muted: #5a6375;
--primary: #2563eb;
--primary-strong: #1d4ed8;
--accent: #7c3aed;
--card: #ffffff;
--border: rgba(17, 20, 19, 0.08);
--shadow: 0 16px 40px rgba(17, 20, 19, 0.08);
@@ -27,6 +27,66 @@ body {
min-height: 100vh;
}
.toast-container {
position: fixed;
top: 20px;
right: 20px;
display: grid;
gap: 12px;
z-index: 9999;
max-width: min(360px, 90vw);
}
.toast {
background: #ffffff;
border: 1px solid var(--border);
border-left: 4px solid var(--primary);
border-radius: 12px;
padding: 12px 14px;
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 16px 40px rgba(17, 20, 19, 0.12);
animation: toast-in 0.2s ease-out;
}
.toast span {
flex: 1;
font-size: 14px;
color: var(--ink);
}
.toast button {
border: none;
background: transparent;
font-size: 18px;
cursor: pointer;
color: var(--muted);
}
.toast-success {
border-left-color: #1d4ed8;
}
.toast-error {
border-left-color: #dc2626;
}
.toast-info {
border-left-color: #2563eb;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.app {
max-width: 1200px;
margin: 0 auto;
@@ -60,40 +120,35 @@ h1 {
font-size: 16px;
}
.lang {
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 12px 16px;
box-shadow: var(--shadow);
min-width: 200px;
.lang-compact {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.lang span {
display: block;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
.lang-compact .user-label {
font-size: 12px;
color: var(--muted);
margin-bottom: 10px;
}
.lang-buttons {
display: flex;
gap: 8px;
gap: 6px;
flex-wrap: wrap;
}
.lang button {
.lang-compact button {
border: 1px solid var(--border);
background: transparent;
padding: 6px 10px;
background: #fff;
padding: 4px 8px;
border-radius: 999px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
}
.lang button.active {
.lang-compact button.active {
background: var(--primary);
color: #fff;
border-color: var(--primary);
@@ -132,7 +187,7 @@ button.primary {
border-radius: 12px;
font-weight: 700;
cursor: pointer;
box-shadow: 0 10px 24px rgba(15, 118, 110, 0.25);
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.25);
}
button.ghost {
@@ -165,7 +220,7 @@ button.ghost {
}
.progress-bar {
background: rgba(15, 118, 110, 0.12);
background: rgba(37, 99, 235, 0.12);
border-radius: 999px;
height: 8px;
overflow: hidden;
@@ -204,6 +259,7 @@ button.ghost {
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
margin-bottom: 28px;
align-items: stretch;
}
.card {
@@ -212,10 +268,80 @@ button.ghost {
border-radius: 18px;
padding: 18px;
box-shadow: 0 12px 28px rgba(17, 20, 19, 0.06);
display: grid;
display: flex;
flex-direction: column;
gap: 10px;
}
.card-actions {
margin-top: auto;
display: grid;
gap: 8px;
}
.card button.primary,
.card button.ghost,
.card .add-button {
width: 100%;
height: 40px;
font-size: 13px;
padding: 0 14px;
}
.card button {
font-size: 13px;
line-height: 1.1;
}
.panel {
background: var(--bg-accent);
border: 1px solid var(--border);
border-radius: 16px;
padding: 16px;
display: grid;
gap: 12px;
}
.section-block {
margin: 16px 0 18px;
padding: 12px 14px;
border-radius: 16px;
border: 1px dashed var(--border);
background: rgba(255, 255, 255, 0.6);
}
.admin-only {
margin: 16px 0 24px;
padding: 16px;
border-radius: 18px;
border: 1px solid rgba(37, 99, 235, 0.2);
background: #f7f9ff;
box-shadow: var(--shadow);
}
.admin-switch {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.section-header h3 {
font-size: 18px;
}
.section-header p {
color: var(--muted);
font-size: 13px;
}
.card h3 {
margin-bottom: 4px;
}
@@ -237,7 +363,9 @@ button.ghost {
input,
select {
width: 100%;
padding: 9px 12px;
padding: 8px 12px;
min-height: 40px;
height: 40px;
border-radius: 10px;
border: 1px solid var(--border);
font-family: inherit;
@@ -245,10 +373,37 @@ select {
}
.toggle {
display: flex;
display: grid;
grid-template-columns: 16px 1fr;
align-items: center;
gap: 8px;
gap: 10px;
font-size: 14px;
line-height: 1.2;
margin: 0;
}
.toggle input {
margin: 0;
justify-self: start;
}
.toggle-group {
display: grid;
gap: 8px;
}
.field-row {
display: grid;
grid-template-columns: 140px 1fr;
gap: 12px;
align-items: center;
font-size: 13px;
}
@media (max-width: 720px) {
.field-row {
grid-template-columns: 1fr;
}
}
.row {
@@ -263,6 +418,30 @@ select {
gap: 8px;
}
.rule-actions {
display: grid;
gap: 8px;
}
.add-button {
border: 1px solid rgba(37, 99, 235, 0.28);
background: linear-gradient(135deg, #f2f6ff, #e2ebff);
color: var(--primary-strong);
padding: 8px 14px;
border-radius: 999px;
font-weight: 600;
cursor: pointer;
width: fit-content;
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.12);
}
.add-button:hover {
transform: translateY(-1px);
border-color: rgba(37, 99, 235, 0.45);
box-shadow: 0 12px 22px rgba(37, 99, 235, 0.18);
}
.list-item {
display: flex;
justify-content: space-between;
@@ -282,7 +461,7 @@ select {
.event {
padding: 10px 12px;
border-radius: 10px;
background: rgba(15, 118, 110, 0.12);
background: rgba(37, 99, 235, 0.12);
display: grid;
grid-template-columns: 40px 1fr;
gap: 12px;
@@ -290,7 +469,7 @@ select {
}
.event.error {
background: rgba(226, 182, 68, 0.25);
background: rgba(124, 58, 237, 0.2);
}
.auth-panel {
@@ -306,6 +485,51 @@ select {
gap: 10px;
}
.hint-text {
font-size: 12px;
color: var(--muted);
line-height: 1.5;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(9, 16, 32, 0.45);
display: grid;
place-items: center;
padding: 20px;
z-index: 1200;
}
.modal {
width: min(640px, 92vw);
background: #fff;
border-radius: 18px;
border: 1px solid var(--border);
box-shadow: 0 24px 60px rgba(17, 20, 19, 0.25);
padding: 20px;
display: grid;
gap: 14px;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.modal-body {
display: grid;
gap: 10px;
font-size: 14px;
color: var(--ink);
}
.modal-body h4 {
margin-top: 6px;
}
.admin-panel {
margin: 16px 0 32px;
display: grid;
@@ -401,13 +625,13 @@ select {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.12em;
background: rgba(15, 118, 110, 0.16);
background: rgba(37, 99, 235, 0.16);
color: var(--primary-strong);
}
.status-badge.missing {
background: rgba(226, 182, 68, 0.25);
color: #8a5d00;
background: rgba(124, 58, 237, 0.18);
color: #5b21b6;
}
button:disabled {

67
frontend/src/toast.tsx Normal file
View File

@@ -0,0 +1,67 @@
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
type ToastVariant = "success" | "error" | "info";
export type Toast = {
id: string;
message: string;
variant: ToastVariant;
};
type ToastContextValue = {
toasts: Toast[];
pushToast: (message: string, variant?: ToastVariant, durationMs?: number) => void;
dismissToast: (id: string) => void;
};
const ToastContext = createContext<ToastContextValue | null>(null);
export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const timers = useRef(new Map<string, number>());
const dismissToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
const timer = timers.current.get(id);
if (timer) {
window.clearTimeout(timer);
timers.current.delete(id);
}
}, []);
const pushToast = useCallback(
(message: string, variant: ToastVariant = "info", durationMs = 4000) => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
setToasts((prev) => [...prev, { id, message, variant }]);
const timer = window.setTimeout(() => dismissToast(id), durationMs);
timers.current.set(id, timer);
},
[dismissToast]
);
const value = useMemo(() => ({ toasts, pushToast, dismissToast }), [toasts, pushToast, dismissToast]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="toast-container" role="status" aria-live="polite">
{toasts.map((toast) => (
<div key={toast.id} className={`toast toast-${toast.variant}`}>
<span>{toast.message}</span>
<button type="button" onClick={() => dismissToast(toast.id)} aria-label="Dismiss">
×
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
};
export const useToast = () => {
const ctx = useContext(ToastContext);
if (!ctx) {
throw new Error("useToast must be used within ToastProvider");
}
return ctx;
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB