Admin UI abtrennen + google settings in gui + UI enhancement
This commit is contained in:
2
.env
2
.env
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
24
backend/node_modules/.prisma/client/edge.js
generated
vendored
24
backend/node_modules/.prisma/client/edge.js
generated
vendored
File diff suppressed because one or more lines are too long
10
backend/node_modules/.prisma/client/index-browser.js
generated
vendored
10
backend/node_modules/.prisma/client/index-browser.js
generated
vendored
@@ -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'
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
1087
backend/node_modules/.prisma/client/index.d.ts
generated
vendored
1087
backend/node_modules/.prisma/client/index.d.ts
generated
vendored
File diff suppressed because it is too large
Load Diff
24
backend/node_modules/.prisma/client/index.js
generated
vendored
24
backend/node_modules/.prisma/client/index.js
generated
vendored
File diff suppressed because one or more lines are too long
2
backend/node_modules/.prisma/client/package.json
generated
vendored
2
backend/node_modules/.prisma/client/package.json
generated
vendored
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "prisma-client-6c67176f7022092b3b8f46007c6286b76456763ea6fcd4c80a580e5070b636e6",
|
||||
"name": "prisma-client-0dfa452a25e24864bcf3f498cd8b34074b00c8171bdce93526b6a0ab38135aa4",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"browser": "index-browser.js",
|
||||
|
||||
21
backend/node_modules/.prisma/client/schema.prisma
generated
vendored
21
backend/node_modules/.prisma/client/schema.prisma
generated
vendored
@@ -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
|
||||
}
|
||||
|
||||
10
backend/node_modules/.prisma/client/wasm.js
generated
vendored
10
backend/node_modules/.prisma/client/wasm.js
generated
vendored
@@ -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'
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 } } });
|
||||
|
||||
27
backend/src/admin/settings.ts
Normal file
27
backend/src/admin/settings.ts
Normal 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 } });
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
39
backend/src/seed.ts
Normal 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
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
|
||||
@@ -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 OAuth‑Client an. Trage die Redirect‑URL unten als erlaubte Weiterleitungs‑URL 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 App‑Passwort oder IMAP‑Passwort ein.",
|
||||
"providerHintWebde": "web.de nutzt IMAP. Gib hier dein App‑Passwort oder IMAP‑Passwort ein.",
|
||||
"providerHelp": "Hilfe zur Mailbox‑Einrichtung",
|
||||
"providerHelpTitle": "Mailbox‑Einrichtung",
|
||||
"providerHelpGmail": "Erstelle einen Google OAuth‑Client und verbinde per OAuth‑Button. Das Passwortfeld ist bei OAuth nicht nötig.",
|
||||
"providerHelpGmx": "Aktiviere IMAP in den GMX‑Einstellungen und erstelle ein App‑Passwort. Dieses Passwort hier verwenden.",
|
||||
"providerHelpWebde": "Aktiviere IMAP in den web.de‑Einstellungen und erstelle ein App‑Passwort. 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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
67
frontend/src/toast.tsx
Normal 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;
|
||||
};
|
||||
BIN
screenshot/Screenshot 2026-01-22 194754.png
Normal file
BIN
screenshot/Screenshot 2026-01-22 194754.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 145 KiB |
Reference in New Issue
Block a user