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_ADMIN_PASSWORD=change-me-now
SEED_TENANT=Default Tenant SEED_TENANT=Default Tenant
SEED_TENANT_ID=seed-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_ADMIN_PASSWORD=change-me-now
SEED_TENANT=Default Tenant SEED_TENANT=Default Tenant
SEED_TENANT_ID=seed-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' createdAt: 'createdAt'
}; };
exports.Prisma.AppSettingScalarFieldEnum = {
id: 'id',
key: 'key',
value: 'value',
updatedAt: 'updatedAt'
};
exports.Prisma.SortOrder = { exports.Prisma.SortOrder = {
asc: 'asc', asc: 'asc',
desc: 'desc' desc: 'desc'
@@ -324,7 +331,8 @@ exports.Prisma.ModelName = {
RuleAction: 'RuleAction', RuleAction: 'RuleAction',
CleanupJob: 'CleanupJob', CleanupJob: 'CleanupJob',
UnsubscribeAttempt: 'UnsubscribeAttempt', 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", "main": "index.js",
"types": "index.d.ts", "types": "index.d.ts",
"browser": "index-browser.js", "browser": "index-browser.js",

View File

@@ -41,6 +41,13 @@ enum RuleConditionType {
LIST_ID LIST_ID
} }
enum ExportStatus {
QUEUED
RUNNING
DONE
FAILED
}
model Tenant { model Tenant {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
@@ -55,13 +62,6 @@ model Tenant {
jobs CleanupJob[] jobs CleanupJob[]
} }
enum ExportStatus {
QUEUED
RUNNING
DONE
FAILED
}
model ExportJob { model ExportJob {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String tenantId String
@@ -240,3 +240,10 @@ model CleanupJobEvent {
@@index([jobId]) @@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' createdAt: 'createdAt'
}; };
exports.Prisma.AppSettingScalarFieldEnum = {
id: 'id',
key: 'key',
value: 'value',
updatedAt: 'updatedAt'
};
exports.Prisma.SortOrder = { exports.Prisma.SortOrder = {
asc: 'asc', asc: 'asc',
desc: 'desc' desc: 'desc'
@@ -324,7 +331,8 @@ exports.Prisma.ModelName = {
RuleAction: 'RuleAction', RuleAction: 'RuleAction',
CleanupJob: 'CleanupJob', CleanupJob: 'CleanupJob',
UnsubscribeAttempt: 'UnsubscribeAttempt', 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 LIST_ID
} }
enum ExportStatus {
QUEUED
RUNNING
DONE
FAILED
}
model Tenant { model Tenant {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
@@ -55,13 +62,6 @@ model Tenant {
jobs CleanupJob[] jobs CleanupJob[]
} }
enum ExportStatus {
QUEUED
RUNNING
DONE
FAILED
}
model ExportJob { model ExportJob {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String tenantId String
@@ -240,3 +240,10 @@ model CleanupJobEvent {
@@index([jobId]) @@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 { createReadStream } from "node:fs";
import { access, unlink } from "node:fs/promises"; import { access, unlink } from "node:fs/promises";
import { cleanupExpiredExports } from "./exportCleanup.js"; import { cleanupExpiredExports } from "./exportCleanup.js";
import { deleteSetting, listSettings, setSetting } from "./settings.js";
const roleSchema = z.object({ const roleSchema = z.object({
role: z.enum(["USER", "ADMIN"]) role: z.enum(["USER", "ADMIN"])
@@ -19,9 +20,53 @@ const resetSchema = z.object({
password: z.string().min(10) 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) { export async function adminRoutes(app: FastifyInstance) {
app.addHook("preHandler", app.requireAdmin); 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 () => { app.get("/tenants", async () => {
const tenants = await prisma.tenant.findMany({ const tenants = await prisma.tenant.findMany({
include: { _count: { select: { users: true, mailboxAccounts: true, jobs: true } } }, 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 } }); const tenant = await prisma.tenant.findUnique({ where: { id: params.id } });
if (!tenant) return reply.code(404).send({ message: "Tenant not found" }); 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 prisma.$transaction(async (tx) => {
await tx.exportJob.deleteMany({ where: { tenantId: tenant.id } });
const jobs = await tx.cleanupJob.findMany({ where: { tenantId: tenant.id } }); const jobs = await tx.cleanupJob.findMany({ where: { tenantId: tenant.id } });
const jobIds = jobs.map((job) => job.id); const jobIds = jobs.map((job) => job.id);
await tx.cleanupJobEvent.deleteMany({ where: { jobId: { in: jobIds } } }); 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_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(), GOOGLE_CLIENT_SECRET: z.string().optional(),
GOOGLE_REDIRECT_URI: 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>; 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_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
GOOGLE_REDIRECT_URI: process.env.GOOGLE_REDIRECT_URI, 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) { if (!parsed.success) {

View File

@@ -2,20 +2,21 @@ import { google } from "googleapis";
import { MailboxAccount } from "@prisma/client"; import { MailboxAccount } from "@prisma/client";
import { config } from "../config.js"; import { config } from "../config.js";
import { prisma } from "../db.js"; import { prisma } from "../db.js";
import { getSetting } from "../admin/settings.js";
const getOAuthClient = () => { const getOAuthClient = async () => {
if (!config.GOOGLE_CLIENT_ID || !config.GOOGLE_CLIENT_SECRET || !config.GOOGLE_REDIRECT_URI) { 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"); throw new Error("Google OAuth config missing");
} }
return new google.auth.OAuth2( return new google.auth.OAuth2(clientId, clientSecret, redirectUri);
config.GOOGLE_CLIENT_ID,
config.GOOGLE_CLIENT_SECRET,
config.GOOGLE_REDIRECT_URI
);
}; };
export const getGmailAuthUrl = (state: string) => { export const getGmailAuthUrl = async (state: string) => {
const client = getOAuthClient(); const client = await getOAuthClient();
return client.generateAuthUrl({ return client.generateAuthUrl({
access_type: "offline", access_type: "offline",
prompt: "consent", prompt: "consent",
@@ -28,7 +29,7 @@ export const getGmailAuthUrl = (state: string) => {
}; };
export const exchangeGmailCode = async (code: string) => { export const exchangeGmailCode = async (code: string) => {
const client = getOAuthClient(); const client = await getOAuthClient();
const { tokens } = await client.getToken(code); const { tokens } = await client.getToken(code);
return tokens; return tokens;
}; };
@@ -49,7 +50,7 @@ export const gmailClientForAccount = async (account: MailboxAccount) => {
throw new Error("Gmail OAuth not configured"); throw new Error("Gmail OAuth not configured");
} }
const client = getOAuthClient(); const client = await getOAuthClient();
client.setCredentials({ client.setCredentials({
refresh_token: account.oauthRefreshToken ?? undefined, refresh_token: account.oauthRefreshToken ?? undefined,
access_token: account.oauthAccessToken ?? 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) { export async function oauthRoutes(app: FastifyInstance) {
app.addHook("preHandler", app.authenticate); 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 input = urlSchema.parse(request.body);
const account = await prisma.mailboxAccount.findFirst({ const account = await prisma.mailboxAccount.findFirst({
where: { id: input.accountId, tenantId: request.user.tenantId, provider: "GMAIL" } 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 state = `${account.id}:${request.user.tenantId}`;
const url = getGmailAuthUrl(state); try {
const url = await getGmailAuthUrl(state);
return { url }; return { url };
} catch {
return reply.code(400).send({ message: "Google OAuth config missing" });
}
}); });
app.get("/gmail/callback", async (request, reply) => { 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" }); 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, { await storeGmailTokens(account.id, {
access_token: tokens.access_token ?? undefined, access_token: tokens.access_token ?? undefined,
refresh_token: tokens.refresh_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 { rulesRoutes } from "./rules/routes.js";
import { adminRoutes } from "./admin/routes.js"; import { adminRoutes } from "./admin/routes.js";
import { oauthRoutes } from "./mail/oauthRoutes.js"; import { oauthRoutes } from "./mail/oauthRoutes.js";
import { ensureSeedData } from "./seed.js";
const app = Fastify({ const app = Fastify({
logger: { logger: {
@@ -26,7 +27,11 @@ const app = Fastify({
trustProxy: config.TRUST_PROXY 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(helmet);
await app.register(jwt, { secret: config.JWT_SECRET }); await app.register(jwt, { secret: config.JWT_SECRET });
await app.register(authPlugin); await app.register(authPlugin);
@@ -48,6 +53,8 @@ await app.register(rulesRoutes, { prefix: "/rules" });
await app.register(adminRoutes, { prefix: "/admin" }); await app.register(adminRoutes, { prefix: "/admin" });
await app.register(oauthRoutes, { prefix: "/oauth" }); await app.register(oauthRoutes, { prefix: "/oauth" });
await ensureSeedData();
const start = async () => { const start = async () => {
try { try {
await app.listen({ port: config.PORT, host: "0.0.0.0" }); 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: depends_on:
- postgres - postgres
- redis - redis
command: ["sh", "-c", "npm run prisma:generate && npm run dev"]
ports: ports:
- "${API_PORT:-8000}:${API_PORT:-8000}" - "${API_PORT:-8000}:${API_PORT:-8000}"
volumes: volumes:
@@ -71,7 +72,7 @@ services:
depends_on: depends_on:
- postgres - postgres
- redis - redis
command: ["npm", "run", "worker:dev"] command: ["sh", "-c", "npm run prisma:generate && npm run worker:dev"]
volumes: volumes:
- ./backend:/app - ./backend:/app

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { apiFetch, createEventSource } from "./api"; import { apiFetch, createEventSource } from "./api";
import AdminPanel from "./admin"; import AdminPanel from "./admin";
import { useToast } from "./toast";
const languages = [ const languages = [
{ code: "de", label: "Deutsch" }, { code: "de", label: "Deutsch" },
@@ -47,9 +48,15 @@ const defaultAction = { type: "MOVE", target: "Newsletter" };
export default function App() { export default function App() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { pushToast } = useToast();
const [activeLang, setActiveLang] = useState(i18n.language); const [activeLang, setActiveLang] = useState(i18n.language);
const [token, setToken] = useState(localStorage.getItem("token") ?? ""); 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 [authEmail, setAuthEmail] = useState("");
const [authPassword, setAuthPassword] = useState(""); const [authPassword, setAuthPassword] = useState("");
const [tenantName, setTenantName] = useState(""); const [tenantName, setTenantName] = useState("");
@@ -58,12 +65,15 @@ export default function App() {
const [accounts, setAccounts] = useState<Account[]>([]); const [accounts, setAccounts] = useState<Account[]>([]);
const [rules, setRules] = useState<Rule[]>([]); const [rules, setRules] = useState<Rule[]>([]);
const [jobs, setJobs] = useState<Job[]>([]); 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 [events, setEvents] = useState<JobEvent[]>([]);
const [accountEmail, setAccountEmail] = useState(""); const [accountEmail, setAccountEmail] = useState("");
const [accountProvider, setAccountProvider] = useState("GMAIL"); const [accountProvider, setAccountProvider] = useState("GMAIL");
const [accountPassword, setAccountPassword] = useState(""); const [accountPassword, setAccountPassword] = useState("");
const [showProviderHelp, setShowProviderHelp] = useState(false);
const [ruleName, setRuleName] = useState(""); const [ruleName, setRuleName] = useState("");
const [ruleEnabled, setRuleEnabled] = useState(true); const [ruleEnabled, setRuleEnabled] = useState(true);
@@ -82,9 +92,28 @@ export default function App() {
const isAuthenticated = useMemo(() => Boolean(token), [token]); 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 loadInitial = async (authToken: string) => {
const me = await apiFetch("/tenants/me", {}, authToken); const me = await apiFetch("/tenants/me", {}, authToken);
setUser(me.user); setUser(me.user);
if (me.user?.role === "ADMIN") {
const stored = localStorage.getItem("ui.showAdmin");
if (stored === null) {
setShowAdmin(false);
}
}
setTenant(me.tenant); setTenant(me.tenant);
const accountsData = await apiFetch("/mail/accounts", {}, authToken); const accountsData = await apiFetch("/mail/accounts", {}, authToken);
@@ -119,12 +148,32 @@ export default function App() {
useEffect(() => { useEffect(() => {
if (!token) return; if (!token) return;
loadInitial(token).catch(() => { loadInitial(token).catch((err: unknown) => {
const status = (err as { status?: number }).status;
if (status === 401 || status === 403) {
setToken(""); setToken("");
localStorage.removeItem("token"); localStorage.removeItem("token");
pushToast(t("toastSessionExpired"), "info");
}
}); });
}, [token]); }, [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(() => { useEffect(() => {
if (!token) return; if (!token) return;
const interval = setInterval(() => { const interval = setInterval(() => {
@@ -156,6 +205,7 @@ export default function App() {
}, [selectedJobId, token]); }, [selectedJobId, token]);
const handleAuth = async () => { const handleAuth = async () => {
try {
if (authMode === "login") { if (authMode === "login") {
const result = await apiFetch( const result = await apiFetch(
"/auth/login", "/auth/login",
@@ -166,6 +216,7 @@ export default function App() {
); );
localStorage.setItem("token", result.token); localStorage.setItem("token", result.token);
setToken(result.token); setToken(result.token);
pushToast(t("toastLoginSuccess"), "success");
return; return;
} }
@@ -178,9 +229,14 @@ export default function App() {
); );
localStorage.setItem("token", result.token); localStorage.setItem("token", result.token);
setToken(result.token); setToken(result.token);
pushToast(t("toastRegisterSuccess"), "success");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
}; };
const handleAddAccount = async () => { const handleAddAccount = async () => {
try {
const result = await apiFetch( const result = await apiFetch(
"/mail/accounts", "/mail/accounts",
{ {
@@ -196,9 +252,14 @@ export default function App() {
setAccounts((prev) => [...prev, result.account]); setAccounts((prev) => [...prev, result.account]);
setAccountEmail(""); setAccountEmail("");
setAccountPassword(""); setAccountPassword("");
pushToast(t("toastMailboxAdded"), "success");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
}; };
const handleAddRule = async () => { const handleAddRule = async () => {
try {
const result = await apiFetch( const result = await apiFetch(
"/rules", "/rules",
{ {
@@ -216,14 +277,24 @@ export default function App() {
setRuleName(""); setRuleName("");
setConditions([{ ...defaultCondition }]); setConditions([{ ...defaultCondition }]);
setActions([{ ...defaultAction }]); setActions([{ ...defaultAction }]);
pushToast(t("toastRuleSaved"), "success");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
}; };
const handleDeleteRule = async (ruleId: string) => { const handleDeleteRule = async (ruleId: string) => {
try {
await apiFetch(`/rules/${ruleId}`, { method: "DELETE" }, token); await apiFetch(`/rules/${ruleId}`, { method: "DELETE" }, token);
setRules((prev) => prev.filter((rule) => rule.id !== ruleId)); setRules((prev) => prev.filter((rule) => rule.id !== ruleId));
pushToast(t("toastRuleDeleted"), "info");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
}; };
const handleStartCleanup = async () => { const handleStartCleanup = async () => {
try {
const result = await apiFetch( const result = await apiFetch(
"/mail/cleanup", "/mail/cleanup",
{ {
@@ -241,6 +312,10 @@ export default function App() {
setJobs(jobsData.jobs ?? []); setJobs(jobsData.jobs ?? []);
setSelectedJobId(result.jobId); setSelectedJobId(result.jobId);
setEvents([]); setEvents([]);
pushToast(t("toastCleanupStarted"), "success");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
}; };
const handleLogout = () => { const handleLogout = () => {
@@ -248,6 +323,7 @@ export default function App() {
localStorage.removeItem("token"); localStorage.removeItem("token");
setUser(null); setUser(null);
setTenant(null); setTenant(null);
pushToast(t("toastLoggedOut"), "info");
}; };
const addCondition = () => setConditions((prev) => [...prev, { ...defaultCondition }]); const addCondition = () => setConditions((prev) => [...prev, { ...defaultCondition }]);
@@ -276,6 +352,7 @@ export default function App() {
}; };
const startGmailOauth = async (accountId: string) => { const startGmailOauth = async (accountId: string) => {
try {
const result = await apiFetch( const result = await apiFetch(
"/oauth/gmail/url", "/oauth/gmail/url",
{ method: "POST", body: JSON.stringify({ accountId }) }, { method: "POST", body: JSON.stringify({ accountId }) },
@@ -284,6 +361,15 @@ export default function App() {
if (result.url) { if (result.url) {
window.location.href = 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) { if (!isAuthenticated) {
@@ -295,8 +381,7 @@ export default function App() {
<h1>{t("appName")}</h1> <h1>{t("appName")}</h1>
<p className="tagline">{t("tagline")}</p> <p className="tagline">{t("tagline")}</p>
</div> </div>
<div className="lang"> <div className="lang-compact" aria-label={t("language")}>
<span>{t("language")}</span>
<div className="lang-buttons"> <div className="lang-buttons">
{languages.map((lang) => ( {languages.map((lang) => (
<button <button
@@ -305,7 +390,7 @@ export default function App() {
className={activeLang === lang.code ? "active" : ""} className={activeLang === lang.code ? "active" : ""}
onClick={() => switchLanguage(lang.code)} onClick={() => switchLanguage(lang.code)}
> >
{lang.label} {lang.code.toUpperCase()}
</button> </button>
))} ))}
</div> </div>
@@ -358,8 +443,8 @@ export default function App() {
<h1>{t("appName")}</h1> <h1>{t("appName")}</h1>
<p className="tagline">{tenant?.name ?? t("tenantFallback")}</p> <p className="tagline">{tenant?.name ?? t("tenantFallback")}</p>
</div> </div>
<div className="lang"> <div className="lang-compact">
<span>{user?.email ?? ""}</span> <span className="user-label">{user?.email ?? ""}</span>
<div className="lang-buttons"> <div className="lang-buttons">
{languages.map((lang) => ( {languages.map((lang) => (
<button <button
@@ -368,7 +453,7 @@ export default function App() {
className={activeLang === lang.code ? "active" : ""} className={activeLang === lang.code ? "active" : ""}
onClick={() => switchLanguage(lang.code)} onClick={() => switchLanguage(lang.code)}
> >
{lang.label} {lang.code.toUpperCase()}
</button> </button>
))} ))}
<button type="button" onClick={handleLogout}>{t("logout")}</button> <button type="button" onClick={handleLogout}>{t("logout")}</button>
@@ -413,8 +498,41 @@ export default function App() {
</div> </div>
</section> </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>
)}
{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"> <section className="grid">
<article className="card"> <article className="card">
<h3>{t("mailboxAdd")}</h3> <h3>{t("mailboxAdd")}</h3>
@@ -433,6 +551,15 @@ export default function App() {
value={accountPassword} value={accountPassword}
onChange={(event) => setAccountPassword(event.target.value)} onChange={(event) => setAccountPassword(event.target.value)}
/> />
<p className="hint-text">{providerHint()}</p>
<div className="card-actions">
<button
className="ghost"
type="button"
onClick={() => setShowProviderHelp(true)}
>
{t("providerHelp")}
</button>
<button className="primary" type="button" onClick={handleAddAccount}> <button className="primary" type="button" onClick={handleAddAccount}>
{t("mailboxSave")} {t("mailboxSave")}
</button> </button>
@@ -441,6 +568,7 @@ export default function App() {
{t("gmailConnect")} {t("gmailConnect")}
</button> </button>
)} )}
</div>
</article> </article>
<article className="card"> <article className="card">
@@ -453,6 +581,7 @@ export default function App() {
</option> </option>
))} ))}
</select> </select>
<div className="toggle-group">
<label className="toggle"> <label className="toggle">
<input type="checkbox" checked={dryRun} onChange={(e) => setDryRun(e.target.checked)} /> <input type="checkbox" checked={dryRun} onChange={(e) => setDryRun(e.target.checked)} />
{t("cleanupDryRun")} {t("cleanupDryRun")}
@@ -473,9 +602,12 @@ export default function App() {
/> />
{t("cleanupRouting")} {t("cleanupRouting")}
</label> </label>
</div>
<div className="card-actions">
<button className="primary" type="button" onClick={handleStartCleanup}> <button className="primary" type="button" onClick={handleStartCleanup}>
{t("start")} {t("start")}
</button> </button>
</div>
</article> </article>
<article className="card"> <article className="card">
@@ -485,10 +617,12 @@ export default function App() {
value={ruleName} value={ruleName}
onChange={(event) => setRuleName(event.target.value)} onChange={(event) => setRuleName(event.target.value)}
/> />
<div className="rule-actions">
<label className="toggle"> <label className="toggle">
<input type="checkbox" checked={ruleEnabled} onChange={(e) => setRuleEnabled(e.target.checked)} /> <input type="checkbox" checked={ruleEnabled} onChange={(e) => setRuleEnabled(e.target.checked)} />
{t("rulesEnabled")} {t("rulesEnabled")}
</label> </label>
</div>
<div className="rule-block"> <div className="rule-block">
<h4>{t("rulesConditions")}</h4> <h4>{t("rulesConditions")}</h4>
{conditions.map((condition, idx) => ( {conditions.map((condition, idx) => (
@@ -522,7 +656,7 @@ export default function App() {
/> />
</div> </div>
))} ))}
<button type="button" onClick={addCondition}>{t("rulesAddCondition")}</button> <button className="add-button" type="button" onClick={addCondition}>{t("rulesAddCondition")}</button>
</div> </div>
<div className="rule-block"> <div className="rule-block">
<h4>{t("rulesActions")}</h4> <h4>{t("rulesActions")}</h4>
@@ -556,14 +690,18 @@ export default function App() {
/> />
</div> </div>
))} ))}
<button type="button" onClick={addAction}>{t("rulesAddAction")}</button> <button className="add-button" type="button" onClick={addAction}>{t("rulesAddAction")}</button>
</div> </div>
<div className="card-actions">
<button className="primary" type="button" onClick={handleAddRule}> <button className="primary" type="button" onClick={handleAddRule}>
{t("rulesSave")} {t("rulesSave")}
</button> </button>
</div>
</article> </article>
</section> </section>
)}
{!showAdmin && (
<section className="grid"> <section className="grid">
<article className="card"> <article className="card">
<h3>{t("adminMailboxStatus")}</h3> <h3>{t("adminMailboxStatus")}</h3>
@@ -613,7 +751,9 @@ export default function App() {
))} ))}
</article> </article>
</section> </section>
)}
{!showAdmin && (
<section className="grid"> <section className="grid">
<article className="card"> <article className="card">
<h3>{t("rulesOverview")}</h3> <h3>{t("rulesOverview")}</h3>
@@ -658,7 +798,28 @@ export default function App() {
)} )}
</article> </article>
</section> </section>
)}
</main> </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> </div>
); );
} }

View File

@@ -3,6 +3,7 @@ import { apiFetch, createEventSourceFor } from "./api";
import { downloadFile } from "./export"; import { downloadFile } from "./export";
import { downloadExport } from "./exportHistory"; import { downloadExport } from "./exportHistory";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useToast } from "./toast";
type Tenant = { type Tenant = {
id: string; id: string;
@@ -39,6 +40,11 @@ type Job = {
mailboxAccount?: { id: string; email: string } | null; mailboxAccount?: { id: string; email: string } | null;
}; };
type SettingValue = {
value: string | null;
source: "db" | "env" | "unset";
};
type Props = { type Props = {
token: string; token: string;
onImpersonate: (token: string) => void; onImpersonate: (token: string) => void;
@@ -46,11 +52,14 @@ type Props = {
export default function AdminPanel({ token, onImpersonate }: Props) { export default function AdminPanel({ token, onImpersonate }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { pushToast } = useToast();
const [tenants, setTenants] = useState<Tenant[]>([]); const [tenants, setTenants] = useState<Tenant[]>([]);
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [accounts, setAccounts] = useState<Account[]>([]); const [accounts, setAccounts] = useState<Account[]>([]);
const [jobs, setJobs] = useState<Job[]>([]); 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 [resetUserId, setResetUserId] = useState<string | null>(null);
const [resetPassword, setResetPassword] = useState(""); const [resetPassword, setResetPassword] = useState("");
const [exportTenantId, setExportTenantId] = useState<string | null>(null); 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 [userSort, setUserSort] = useState<"recent" | "oldest" | "email">("recent");
const [accountSort, setAccountSort] = useState<"recent" | "oldest" | "email">("recent"); const [accountSort, setAccountSort] = useState<"recent" | "oldest" | "email">("recent");
const [jobSort, setJobSort] = useState<"recent" | "oldest" | "status">("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 loadAll = async () => {
const tenantData = await apiFetch("/admin/tenants", {}, token); const tenantData = await apiFetch("/admin/tenants", {}, token);
@@ -77,30 +94,72 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
const jobsData = await apiFetch("/admin/jobs", {}, token); const jobsData = await apiFetch("/admin/jobs", {}, token);
setJobs(jobsData.jobs ?? []); setJobs(jobsData.jobs ?? []);
const exportsData = await apiFetch("/admin/exports", {}, token); const exportsData = await apiFetch("/admin/exports", {}, token);
setExportHistory(exportsData.exports ?? []); 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(() => { useEffect(() => {
loadAll().catch(() => undefined); 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 toggleTenant = async (tenant: Tenant) => {
try {
const result = await apiFetch( const result = await apiFetch(
`/admin/tenants/${tenant.id}`, `/admin/tenants/${tenant.id}`,
{ method: "PUT", body: JSON.stringify({ isActive: !tenant.isActive }) }, { method: "PUT", body: JSON.stringify({ isActive: !tenant.isActive }) },
token token
); );
setTenants((prev) => prev.map((item) => (item.id === tenant.id ? result.tenant : item))); 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) => { const exportTenant = async (tenant: Tenant) => {
try {
setExportTenantId(tenant.id); setExportTenantId(tenant.id);
setExportStatus("loading"); setExportStatus("loading");
if (exportFormat === "json") { if (exportFormat === "json") {
const result = await apiFetch(`/admin/tenants/${tenant.id}/export?scope=${exportScope}`, {}, token); const result = await apiFetch(`/admin/tenants/${tenant.id}/export?scope=${exportScope}`, {}, token);
const blob = new Blob([JSON.stringify(result, null, 2)], { type: "application/json" }); const blob = new Blob([JSON.stringify(result, null, 2)], { type: "application/json" });
downloadFile(blob, `tenant-${tenant.id}.json`); downloadFile(blob, `tenant-${tenant.id}.json`);
pushToast(t("toastExportReady"), "success");
} else if (exportFormat === "csv") { } else if (exportFormat === "csv") {
await exportTenantCsv(tenant); await exportTenantCsv(tenant);
return; return;
@@ -108,6 +167,7 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
const result = await apiFetch(`/admin/tenants/${tenant.id}/export?format=zip&scope=${exportScope}`, {}, token); const result = await apiFetch(`/admin/tenants/${tenant.id}/export?format=zip&scope=${exportScope}`, {}, token);
setExportJobId(result.jobId); setExportJobId(result.jobId);
setExportHistory((prev) => [{ id: result.jobId, status: "QUEUED" }, ...prev]); setExportHistory((prev) => [{ id: result.jobId, status: "QUEUED" }, ...prev]);
pushToast(t("toastExportQueued"), "info");
const source = createEventSourceFor(`exports/${result.jobId}`, token); const source = createEventSourceFor(`exports/${result.jobId}`, token);
source.onmessage = async (event) => { source.onmessage = async (event) => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
@@ -119,10 +179,12 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
const blob = await response.blob(); const blob = await response.blob();
downloadFile(blob, `tenant-${tenant.id}.zip`); downloadFile(blob, `tenant-${tenant.id}.zip`);
setExportStatus("done"); setExportStatus("done");
pushToast(t("toastExportReady"), "success");
source.close(); source.close();
setTimeout(() => setExportStatus("idle"), 1500); setTimeout(() => setExportStatus("idle"), 1500);
} else if (data.status === "FAILED") { } else if (data.status === "FAILED") {
setExportStatus("failed"); setExportStatus("failed");
pushToast(t("toastExportFailed"), "error");
source.close(); source.close();
} }
}; };
@@ -130,6 +192,10 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
} }
setExportStatus("done"); setExportStatus("done");
setTimeout(() => setExportStatus("idle"), 1500); setTimeout(() => setExportStatus("idle"), 1500);
} catch (err) {
setExportStatus("failed");
pushToast(getErrorMessage(err), "error");
}
}; };
const exportTenantCsv = async (tenant: Tenant) => { 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}`, { const response = await fetch(`${base}/admin/tenants/${tenant.id}/export?format=csv&scope=${exportScope}`, {
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
}); });
if (!response.ok) {
throw new Error(await response.text());
}
const text = await response.text(); const text = await response.text();
const blob = new Blob([text], { type: "text/csv" }); const blob = new Blob([text], { type: "text/csv" });
downloadFile(blob, `tenant-${tenant.id}.csv`); downloadFile(blob, `tenant-${tenant.id}.csv`);
setExportStatus("done"); setExportStatus("done");
setTimeout(() => setExportStatus("idle"), 1500); setTimeout(() => setExportStatus("idle"), 1500);
pushToast(t("toastExportReady"), "success");
}; };
const deleteTenant = async (tenant: Tenant) => { const deleteTenant = async (tenant: Tenant) => {
if (!confirm(t("adminDeleteConfirm", { name: tenant.name }))) return; if (!confirm(t("adminDeleteConfirm", { name: tenant.name }))) return;
try {
await apiFetch(`/admin/tenants/${tenant.id}`, { method: "DELETE" }, token); await apiFetch(`/admin/tenants/${tenant.id}`, { method: "DELETE" }, token);
setTenants((prev) => prev.filter((item) => item.id !== tenant.id)); 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 toggleUser = async (user: User) => {
try {
const result = await apiFetch( const result = await apiFetch(
`/admin/users/${user.id}`, `/admin/users/${user.id}`,
{ method: "PUT", body: JSON.stringify({ isActive: !user.isActive }) }, { method: "PUT", body: JSON.stringify({ isActive: !user.isActive }) },
token token
); );
setUsers((prev) => prev.map((item) => (item.id === user.id ? result.user : item))); 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 toggleAccount = async (account: Account) => {
try {
const result = await apiFetch( const result = await apiFetch(
`/admin/accounts/${account.id}`, `/admin/accounts/${account.id}`,
{ method: "PUT", body: JSON.stringify({ isActive: !account.isActive }) }, { method: "PUT", body: JSON.stringify({ isActive: !account.isActive }) },
token token
); );
setAccounts((prev) => prev.map((item) => (item.id === account.id ? result.account : item))); 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 setRole = async (user: User, role: "USER" | "ADMIN") => {
try {
const result = await apiFetch( const result = await apiFetch(
`/admin/users/${user.id}/role`, `/admin/users/${user.id}/role`,
{ method: "PUT", body: JSON.stringify({ role }) }, { method: "PUT", body: JSON.stringify({ role }) },
token token
); );
setUsers((prev) => prev.map((item) => (item.id === user.id ? result.user : item))); 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 impersonate = async (user: User) => {
try {
const result = await apiFetch(`/admin/impersonate/${user.id}`, { method: "POST" }, token); const result = await apiFetch(`/admin/impersonate/${user.id}`, { method: "POST" }, token);
onImpersonate(result.token); onImpersonate(result.token);
pushToast(t("toastImpersonate"), "info");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
}; };
const resetPasswordForUser = async () => { const resetPasswordForUser = async () => {
if (!resetUserId || resetPassword.length < 10) return; if (!resetUserId || resetPassword.length < 10) return;
try {
await apiFetch(`/admin/users/${resetUserId}/reset`, { await apiFetch(`/admin/users/${resetUserId}/reset`, {
method: "POST", method: "POST",
body: JSON.stringify({ password: resetPassword }) body: JSON.stringify({ password: resetPassword })
}, token); }, token);
setResetUserId(null); setResetUserId(null);
setResetPassword(""); setResetPassword("");
pushToast(t("toastPasswordReset"), "success");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
}; };
const cancelJob = async (job: Job) => { const cancelJob = async (job: Job) => {
try {
await apiFetch(`/admin/jobs/${job.id}/cancel`, { method: "POST" }, token); await apiFetch(`/admin/jobs/${job.id}/cancel`, { method: "POST" }, token);
setJobs((prev) => prev.map((item) => (item.id === job.id ? { ...item, status: "CANCELED" } : item))); 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) => { const retryJob = async (job: Job) => {
try {
await apiFetch(`/admin/jobs/${job.id}/retry`, { method: "POST" }, token); await apiFetch(`/admin/jobs/${job.id}/retry`, { method: "POST" }, token);
loadAll().catch(() => undefined); loadAll().catch(() => undefined);
pushToast(t("toastJobRetry"), "success");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
}; };
const sortBy = <T,>(items: T[], mode: string, getKey: (item: T) => string) => { 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 ( return (
<section className="admin-panel"> <section className="admin-panel">
<div className="admin-tabs"> <div className="admin-tabs">
{(["tenants", "users", "accounts", "jobs"] as const).map((tab) => ( {(["tenants", "users", "accounts", "jobs", "settings"] as const).map((tab) => (
<button <button
key={tab} key={tab}
className={activeTab === tab ? "active" : ""} className={activeTab === tab ? "active" : ""}
@@ -273,7 +410,7 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
<div className="export-panel"> <div className="export-panel">
<h4>{t("adminTenantExport")}</h4> <h4>{t("adminTenantExport")}</h4>
<p className="status-note">{t("adminExportHint")}</p> <p className="status-note">{t("adminExportHint")}</p>
<label className="toggle"> <label className="field-row">
<span>{t("adminExportScope")}</span> <span>{t("adminExportScope")}</span>
<select value={exportScope} onChange={(event) => setExportScope(event.target.value as typeof exportScope)}> <select value={exportScope} onChange={(event) => setExportScope(event.target.value as typeof exportScope)}>
<option value="all">{t("adminExportAll")}</option> <option value="all">{t("adminExportAll")}</option>
@@ -283,7 +420,7 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
<option value="rules">{t("adminExportRules")}</option> <option value="rules">{t("adminExportRules")}</option>
</select> </select>
</label> </label>
<label className="toggle"> <label className="field-row">
<span>{t("adminExportFormat")}</span> <span>{t("adminExportFormat")}</span>
<select value={exportFormat} onChange={(event) => setExportFormat(event.target.value as typeof exportFormat)}> <select value={exportFormat} onChange={(event) => setExportFormat(event.target.value as typeof exportFormat)}>
<option value="json">{t("exportFormatJson")}</option> <option value="json">{t("exportFormatJson")}</option>
@@ -344,8 +481,13 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
<button <button
className="ghost" className="ghost"
onClick={async () => { onClick={async () => {
try {
await apiFetch("/admin/exports/purge", { method: "POST" }, token); await apiFetch("/admin/exports/purge", { method: "POST" }, token);
loadAll().catch(() => undefined); loadAll().catch(() => undefined);
pushToast(t("toastExportPurged"), "info");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
}} }}
> >
{t("adminExportPurge")} {t("adminExportPurge")}
@@ -391,8 +533,13 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
<button <button
className="ghost" className="ghost"
onClick={async () => { onClick={async () => {
try {
await apiFetch(`/admin/exports/${item.id}`, { method: "DELETE" }, token); await apiFetch(`/admin/exports/${item.id}`, { method: "DELETE" }, token);
loadAll().catch(() => undefined); loadAll().catch(() => undefined);
pushToast(t("toastExportDeleted"), "info");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
}} }}
> >
{t("delete")} {t("delete")}
@@ -541,6 +688,56 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
))} ))}
</div> </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> </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) => { export const apiFetch = async (path: string, options: RequestInit = {}, token?: string) => {
const headers = new Headers(options.headers ?? {}); const headers = new Headers(options.headers ?? {});
const hasBody = options.body !== undefined && options.body !== null;
if (hasBody && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json"); headers.set("Content-Type", "application/json");
}
if (token) { if (token) {
headers.set("Authorization", `Bearer ${token}`); headers.set("Authorization", `Bearer ${token}`);
} }
@@ -14,7 +17,9 @@ export const apiFetch = async (path: string, options: RequestInit = {}, token?:
if (!response.ok) { if (!response.ok) {
const message = await response.text(); 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(); return response.json();

View File

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

View File

@@ -31,6 +31,7 @@
"adminUsers": "User", "adminUsers": "User",
"adminAccounts": "Accounts", "adminAccounts": "Accounts",
"adminJobs": "Jobs", "adminJobs": "Jobs",
"adminSettings": "Einstellungen",
"adminExport": "Export", "adminExport": "Export",
"adminDisable": "Deaktivieren", "adminDisable": "Deaktivieren",
"adminEnable": "Aktivieren", "adminEnable": "Aktivieren",
@@ -43,6 +44,19 @@
"adminResetPlaceholder": "Neues Passwort (min 10 Zeichen)", "adminResetPlaceholder": "Neues Passwort (min 10 Zeichen)",
"adminCancel": "Abbrechen", "adminCancel": "Abbrechen",
"adminConfirmReset": "Reset", "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", "adminRetry": "Retry",
"adminCancelJob": "Cancel", "adminCancelJob": "Cancel",
"adminMailboxStatus": "Mailbox Status", "adminMailboxStatus": "Mailbox Status",
@@ -63,7 +77,7 @@
"rulesActions": "Aktionen", "rulesActions": "Aktionen",
"rulesAddCondition": "+ Bedingung", "rulesAddCondition": "+ Bedingung",
"rulesAddAction": "+ Aktion", "rulesAddAction": "+ Aktion",
"rulesSave": "Rule speichern", "rulesSave": "Regel speichern",
"rulesOverview": "Regeln Übersicht", "rulesOverview": "Regeln Übersicht",
"jobsTitle": "Jobs", "jobsTitle": "Jobs",
"jobEvents": "Job Events", "jobEvents": "Job Events",
@@ -83,6 +97,15 @@
"countAccounts": "{{count}} Accounts", "countAccounts": "{{count}} Accounts",
"countJobs": "{{count}} Jobs", "countJobs": "{{count}} Jobs",
"placeholderEmail": "email@example.com", "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", "providerGmail": "Gmail",
"providerGmx": "GMX", "providerGmx": "GMX",
"providerWebde": "web.de", "providerWebde": "web.de",
@@ -153,5 +176,34 @@
"exportStatusFailed": "Fehlgeschlagen", "exportStatusFailed": "Fehlgeschlagen",
"exportStatusExpired": "Abgelaufen", "exportStatusExpired": "Abgelaufen",
"adminExportPurge": "Abgelaufene löschen", "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", "adminUsers": "Users",
"adminAccounts": "Accounts", "adminAccounts": "Accounts",
"adminJobs": "Jobs", "adminJobs": "Jobs",
"adminSettings": "Settings",
"adminExport": "Export", "adminExport": "Export",
"adminDisable": "Disable", "adminDisable": "Disable",
"adminEnable": "Enable", "adminEnable": "Enable",
@@ -43,6 +44,19 @@
"adminResetPlaceholder": "New password (min 10 characters)", "adminResetPlaceholder": "New password (min 10 characters)",
"adminCancel": "Cancel", "adminCancel": "Cancel",
"adminConfirmReset": "Reset", "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", "adminRetry": "Retry",
"adminCancelJob": "Cancel", "adminCancelJob": "Cancel",
"adminMailboxStatus": "Mailbox status", "adminMailboxStatus": "Mailbox status",
@@ -83,6 +97,15 @@
"countAccounts": "{{count}} accounts", "countAccounts": "{{count}} accounts",
"countJobs": "{{count}} jobs", "countJobs": "{{count}} jobs",
"placeholderEmail": "email@example.com", "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", "providerGmail": "Gmail",
"providerGmx": "GMX", "providerGmx": "GMX",
"providerWebde": "web.de", "providerWebde": "web.de",
@@ -153,5 +176,34 @@
"exportStatusFailed": "Failed", "exportStatusFailed": "Failed",
"exportStatusExpired": "Expired", "exportStatusExpired": "Expired",
"adminExportPurge": "Purge 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 "./i18n";
import "./styles.css"; import "./styles.css";
import App from "./App"; import App from "./App";
import { ToastProvider } from "./toast";
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<ToastProvider>
<App /> <App />
</ToastProvider>
</React.StrictMode> </React.StrictMode>
); );

View File

@@ -2,13 +2,13 @@
:root { :root {
color-scheme: light; color-scheme: light;
--bg: #f4f6f3; --bg: #f3f6fb;
--bg-accent: #eef1ea; --bg-accent: #e9f0ff;
--ink: #111413; --ink: #101827;
--muted: #5b615b; --muted: #5a6375;
--primary: #0f766e; --primary: #2563eb;
--primary-strong: #0b5f59; --primary-strong: #1d4ed8;
--accent: #e2b644; --accent: #7c3aed;
--card: #ffffff; --card: #ffffff;
--border: rgba(17, 20, 19, 0.08); --border: rgba(17, 20, 19, 0.08);
--shadow: 0 16px 40px rgba(17, 20, 19, 0.08); --shadow: 0 16px 40px rgba(17, 20, 19, 0.08);
@@ -27,6 +27,66 @@ body {
min-height: 100vh; 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 { .app {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
@@ -60,40 +120,35 @@ h1 {
font-size: 16px; font-size: 16px;
} }
.lang { .lang-compact {
background: var(--card); display: flex;
border: 1px solid var(--border); flex-direction: column;
border-radius: 16px; align-items: flex-end;
padding: 12px 16px; gap: 6px;
box-shadow: var(--shadow);
min-width: 200px;
} }
.lang span { .lang-compact .user-label {
display: block; font-size: 12px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted); color: var(--muted);
margin-bottom: 10px;
} }
.lang-buttons { .lang-buttons {
display: flex; display: flex;
gap: 8px; gap: 6px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.lang button { .lang-compact button {
border: 1px solid var(--border); border: 1px solid var(--border);
background: transparent; background: #fff;
padding: 6px 10px; padding: 4px 8px;
border-radius: 999px; border-radius: 999px;
cursor: pointer; cursor: pointer;
font-size: 12px;
font-weight: 600; font-weight: 600;
} }
.lang button.active { .lang-compact button.active {
background: var(--primary); background: var(--primary);
color: #fff; color: #fff;
border-color: var(--primary); border-color: var(--primary);
@@ -132,7 +187,7 @@ button.primary {
border-radius: 12px; border-radius: 12px;
font-weight: 700; font-weight: 700;
cursor: pointer; 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 { button.ghost {
@@ -165,7 +220,7 @@ button.ghost {
} }
.progress-bar { .progress-bar {
background: rgba(15, 118, 110, 0.12); background: rgba(37, 99, 235, 0.12);
border-radius: 999px; border-radius: 999px;
height: 8px; height: 8px;
overflow: hidden; overflow: hidden;
@@ -204,6 +259,7 @@ button.ghost {
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px; gap: 16px;
margin-bottom: 28px; margin-bottom: 28px;
align-items: stretch;
} }
.card { .card {
@@ -212,10 +268,80 @@ button.ghost {
border-radius: 18px; border-radius: 18px;
padding: 18px; padding: 18px;
box-shadow: 0 12px 28px rgba(17, 20, 19, 0.06); box-shadow: 0 12px 28px rgba(17, 20, 19, 0.06);
display: grid; display: flex;
flex-direction: column;
gap: 10px; 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 { .card h3 {
margin-bottom: 4px; margin-bottom: 4px;
} }
@@ -237,7 +363,9 @@ button.ghost {
input, input,
select { select {
width: 100%; width: 100%;
padding: 9px 12px; padding: 8px 12px;
min-height: 40px;
height: 40px;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--border); border: 1px solid var(--border);
font-family: inherit; font-family: inherit;
@@ -245,10 +373,37 @@ select {
} }
.toggle { .toggle {
display: flex; display: grid;
grid-template-columns: 16px 1fr;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
font-size: 14px; 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 { .row {
@@ -263,6 +418,30 @@ select {
gap: 8px; 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 { .list-item {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -282,7 +461,7 @@ select {
.event { .event {
padding: 10px 12px; padding: 10px 12px;
border-radius: 10px; border-radius: 10px;
background: rgba(15, 118, 110, 0.12); background: rgba(37, 99, 235, 0.12);
display: grid; display: grid;
grid-template-columns: 40px 1fr; grid-template-columns: 40px 1fr;
gap: 12px; gap: 12px;
@@ -290,7 +469,7 @@ select {
} }
.event.error { .event.error {
background: rgba(226, 182, 68, 0.25); background: rgba(124, 58, 237, 0.2);
} }
.auth-panel { .auth-panel {
@@ -306,6 +485,51 @@ select {
gap: 10px; 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 { .admin-panel {
margin: 16px 0 32px; margin: 16px 0 32px;
display: grid; display: grid;
@@ -401,13 +625,13 @@ select {
font-size: 10px; font-size: 10px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.12em; letter-spacing: 0.12em;
background: rgba(15, 118, 110, 0.16); background: rgba(37, 99, 235, 0.16);
color: var(--primary-strong); color: var(--primary-strong);
} }
.status-badge.missing { .status-badge.missing {
background: rgba(226, 182, 68, 0.25); background: rgba(124, 58, 237, 0.18);
color: #8a5d00; color: #5b21b6;
} }
button:disabled { 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