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_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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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'
|
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'
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
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",
|
"main": "index.js",
|
||||||
"types": "index.d.ts",
|
"types": "index.d.ts",
|
||||||
"browser": "index-browser.js",
|
"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
|
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
|
||||||
|
}
|
||||||
|
|||||||
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'
|
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'
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 } } });
|
||||||
|
|||||||
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_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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
return { url };
|
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) => {
|
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,
|
||||||
|
|||||||
@@ -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
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:
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
setToken("");
|
const status = (err as { status?: number }).status;
|
||||||
localStorage.removeItem("token");
|
if (status === 401 || status === 403) {
|
||||||
|
setToken("");
|
||||||
|
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,91 +205,117 @@ export default function App() {
|
|||||||
}, [selectedJobId, token]);
|
}, [selectedJobId, token]);
|
||||||
|
|
||||||
const handleAuth = async () => {
|
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(
|
const result = await apiFetch(
|
||||||
"/auth/login",
|
"/auth/register",
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ email: authEmail, password: authPassword })
|
body: JSON.stringify({ tenantName, email: authEmail, password: authPassword })
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
localStorage.setItem("token", result.token);
|
localStorage.setItem("token", result.token);
|
||||||
setToken(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 handleAddAccount = async () => {
|
||||||
const result = await apiFetch(
|
try {
|
||||||
"/mail/accounts",
|
const result = await apiFetch(
|
||||||
{
|
"/mail/accounts",
|
||||||
method: "POST",
|
{
|
||||||
body: JSON.stringify({
|
method: "POST",
|
||||||
email: accountEmail,
|
body: JSON.stringify({
|
||||||
provider: accountProvider,
|
email: accountEmail,
|
||||||
appPassword: accountPassword || undefined
|
provider: accountProvider,
|
||||||
})
|
appPassword: accountPassword || undefined
|
||||||
},
|
})
|
||||||
token
|
},
|
||||||
);
|
token
|
||||||
setAccounts((prev) => [...prev, result.account]);
|
);
|
||||||
setAccountEmail("");
|
setAccounts((prev) => [...prev, result.account]);
|
||||||
setAccountPassword("");
|
setAccountEmail("");
|
||||||
|
setAccountPassword("");
|
||||||
|
pushToast(t("toastMailboxAdded"), "success");
|
||||||
|
} catch (err) {
|
||||||
|
pushToast(getErrorMessage(err), "error");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddRule = async () => {
|
const handleAddRule = async () => {
|
||||||
const result = await apiFetch(
|
try {
|
||||||
"/rules",
|
const result = await apiFetch(
|
||||||
{
|
"/rules",
|
||||||
method: "POST",
|
{
|
||||||
body: JSON.stringify({
|
method: "POST",
|
||||||
name: ruleName,
|
body: JSON.stringify({
|
||||||
enabled: ruleEnabled,
|
name: ruleName,
|
||||||
conditions,
|
enabled: ruleEnabled,
|
||||||
actions
|
conditions,
|
||||||
})
|
actions
|
||||||
},
|
})
|
||||||
token
|
},
|
||||||
);
|
token
|
||||||
setRules((prev) => [...prev, result.rule]);
|
);
|
||||||
setRuleName("");
|
setRules((prev) => [...prev, result.rule]);
|
||||||
setConditions([{ ...defaultCondition }]);
|
setRuleName("");
|
||||||
setActions([{ ...defaultAction }]);
|
setConditions([{ ...defaultCondition }]);
|
||||||
|
setActions([{ ...defaultAction }]);
|
||||||
|
pushToast(t("toastRuleSaved"), "success");
|
||||||
|
} catch (err) {
|
||||||
|
pushToast(getErrorMessage(err), "error");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteRule = async (ruleId: string) => {
|
const handleDeleteRule = async (ruleId: string) => {
|
||||||
await apiFetch(`/rules/${ruleId}`, { method: "DELETE" }, token);
|
try {
|
||||||
setRules((prev) => prev.filter((rule) => rule.id !== ruleId));
|
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 handleStartCleanup = async () => {
|
||||||
const result = await apiFetch(
|
try {
|
||||||
"/mail/cleanup",
|
const result = await apiFetch(
|
||||||
{
|
"/mail/cleanup",
|
||||||
method: "POST",
|
{
|
||||||
body: JSON.stringify({
|
method: "POST",
|
||||||
mailboxAccountId: cleanupAccountId,
|
body: JSON.stringify({
|
||||||
dryRun,
|
mailboxAccountId: cleanupAccountId,
|
||||||
unsubscribeEnabled,
|
dryRun,
|
||||||
routingEnabled
|
unsubscribeEnabled,
|
||||||
})
|
routingEnabled
|
||||||
},
|
})
|
||||||
token
|
},
|
||||||
);
|
token
|
||||||
const jobsData = await apiFetch("/jobs", {}, token);
|
);
|
||||||
setJobs(jobsData.jobs ?? []);
|
const jobsData = await apiFetch("/jobs", {}, token);
|
||||||
setSelectedJobId(result.jobId);
|
setJobs(jobsData.jobs ?? []);
|
||||||
setEvents([]);
|
setSelectedJobId(result.jobId);
|
||||||
|
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,16 +352,26 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const startGmailOauth = async (accountId: string) => {
|
const startGmailOauth = async (accountId: string) => {
|
||||||
const result = await apiFetch(
|
try {
|
||||||
"/oauth/gmail/url",
|
const result = await apiFetch(
|
||||||
{ method: "POST", body: JSON.stringify({ accountId }) },
|
"/oauth/gmail/url",
|
||||||
token
|
{ method: "POST", body: JSON.stringify({ accountId }) },
|
||||||
);
|
token
|
||||||
if (result.url) {
|
);
|
||||||
window.location.href = result.url;
|
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) {
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<div className="app auth">
|
<div className="app auth">
|
||||||
@@ -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,9 +498,42 @@ 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>
|
||||||
|
)}
|
||||||
|
|
||||||
<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">
|
<article className="card">
|
||||||
<h3>{t("mailboxAdd")}</h3>
|
<h3>{t("mailboxAdd")}</h3>
|
||||||
<input
|
<input
|
||||||
@@ -433,14 +551,24 @@ export default function App() {
|
|||||||
value={accountPassword}
|
value={accountPassword}
|
||||||
onChange={(event) => setAccountPassword(event.target.value)}
|
onChange={(event) => setAccountPassword(event.target.value)}
|
||||||
/>
|
/>
|
||||||
<button className="primary" type="button" onClick={handleAddAccount}>
|
<p className="hint-text">{providerHint()}</p>
|
||||||
{t("mailboxSave")}
|
<div className="card-actions">
|
||||||
</button>
|
<button
|
||||||
{accountProvider === "GMAIL" && cleanupAccountId && (
|
className="ghost"
|
||||||
<button className="ghost" type="button" onClick={() => startGmailOauth(cleanupAccountId)}>
|
type="button"
|
||||||
{t("gmailConnect")}
|
onClick={() => setShowProviderHelp(true)}
|
||||||
|
>
|
||||||
|
{t("providerHelp")}
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<article className="card">
|
<article className="card">
|
||||||
@@ -453,29 +581,33 @@ export default function App() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<label className="toggle">
|
<div className="toggle-group">
|
||||||
<input type="checkbox" checked={dryRun} onChange={(e) => setDryRun(e.target.checked)} />
|
<label className="toggle">
|
||||||
{t("cleanupDryRun")}
|
<input type="checkbox" checked={dryRun} onChange={(e) => setDryRun(e.target.checked)} />
|
||||||
</label>
|
{t("cleanupDryRun")}
|
||||||
<label className="toggle">
|
</label>
|
||||||
<input
|
<label className="toggle">
|
||||||
type="checkbox"
|
<input
|
||||||
checked={unsubscribeEnabled}
|
type="checkbox"
|
||||||
onChange={(e) => setUnsubscribeEnabled(e.target.checked)}
|
checked={unsubscribeEnabled}
|
||||||
/>
|
onChange={(e) => setUnsubscribeEnabled(e.target.checked)}
|
||||||
{t("cleanupUnsubscribe")}
|
/>
|
||||||
</label>
|
{t("cleanupUnsubscribe")}
|
||||||
<label className="toggle">
|
</label>
|
||||||
<input
|
<label className="toggle">
|
||||||
type="checkbox"
|
<input
|
||||||
checked={routingEnabled}
|
type="checkbox"
|
||||||
onChange={(e) => setRoutingEnabled(e.target.checked)}
|
checked={routingEnabled}
|
||||||
/>
|
onChange={(e) => setRoutingEnabled(e.target.checked)}
|
||||||
{t("cleanupRouting")}
|
/>
|
||||||
</label>
|
{t("cleanupRouting")}
|
||||||
<button className="primary" type="button" onClick={handleStartCleanup}>
|
</label>
|
||||||
{t("start")}
|
</div>
|
||||||
</button>
|
<div className="card-actions">
|
||||||
|
<button className="primary" type="button" onClick={handleStartCleanup}>
|
||||||
|
{t("start")}
|
||||||
|
</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)}
|
||||||
/>
|
/>
|
||||||
<label className="toggle">
|
<div className="rule-actions">
|
||||||
<input type="checkbox" checked={ruleEnabled} onChange={(e) => setRuleEnabled(e.target.checked)} />
|
<label className="toggle">
|
||||||
{t("rulesEnabled")}
|
<input type="checkbox" checked={ruleEnabled} onChange={(e) => setRuleEnabled(e.target.checked)} />
|
||||||
</label>
|
{t("rulesEnabled")}
|
||||||
|
</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,15 +690,19 @@ 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 className="card-actions">
|
||||||
|
<button className="primary" type="button" onClick={handleAddRule}>
|
||||||
|
{t("rulesSave")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button className="primary" type="button" onClick={handleAddRule}>
|
|
||||||
{t("rulesSave")}
|
|
||||||
</button>
|
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="grid">
|
{!showAdmin && (
|
||||||
|
<section className="grid">
|
||||||
<article className="card">
|
<article className="card">
|
||||||
<h3>{t("adminMailboxStatus")}</h3>
|
<h3>{t("adminMailboxStatus")}</h3>
|
||||||
{accounts.map((account) => (
|
{accounts.map((account) => (
|
||||||
@@ -613,8 +751,10 @@ export default function App() {
|
|||||||
))}
|
))}
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="grid">
|
{!showAdmin && (
|
||||||
|
<section className="grid">
|
||||||
<article className="card">
|
<article className="card">
|
||||||
<h3>{t("rulesOverview")}</h3>
|
<h3>{t("rulesOverview")}</h3>
|
||||||
{rules.map((rule) => (
|
{rules.map((rule) => (
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,59 +94,108 @@ 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);
|
|
||||||
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(() => {
|
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) => {
|
||||||
const result = await apiFetch(
|
try {
|
||||||
`/admin/tenants/${tenant.id}`,
|
const result = await apiFetch(
|
||||||
{ method: "PUT", body: JSON.stringify({ isActive: !tenant.isActive }) },
|
`/admin/tenants/${tenant.id}`,
|
||||||
token
|
{ method: "PUT", body: JSON.stringify({ isActive: !tenant.isActive }) },
|
||||||
);
|
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) => {
|
||||||
setExportTenantId(tenant.id);
|
try {
|
||||||
setExportStatus("loading");
|
setExportTenantId(tenant.id);
|
||||||
if (exportFormat === "json") {
|
setExportStatus("loading");
|
||||||
const result = await apiFetch(`/admin/tenants/${tenant.id}/export?scope=${exportScope}`, {}, token);
|
if (exportFormat === "json") {
|
||||||
const blob = new Blob([JSON.stringify(result, null, 2)], { type: "application/json" });
|
const result = await apiFetch(`/admin/tenants/${tenant.id}/export?scope=${exportScope}`, {}, token);
|
||||||
downloadFile(blob, `tenant-${tenant.id}.json`);
|
const blob = new Blob([JSON.stringify(result, null, 2)], { type: "application/json" });
|
||||||
} else if (exportFormat === "csv") {
|
downloadFile(blob, `tenant-${tenant.id}.json`);
|
||||||
await exportTenantCsv(tenant);
|
pushToast(t("toastExportReady"), "success");
|
||||||
return;
|
} else if (exportFormat === "csv") {
|
||||||
} else {
|
await exportTenantCsv(tenant);
|
||||||
const result = await apiFetch(`/admin/tenants/${tenant.id}/export?format=zip&scope=${exportScope}`, {}, token);
|
return;
|
||||||
setExportJobId(result.jobId);
|
} else {
|
||||||
setExportHistory((prev) => [{ id: result.jobId, status: "QUEUED" }, ...prev]);
|
const result = await apiFetch(`/admin/tenants/${tenant.id}/export?format=zip&scope=${exportScope}`, {}, token);
|
||||||
const source = createEventSourceFor(`exports/${result.jobId}`, token);
|
setExportJobId(result.jobId);
|
||||||
source.onmessage = async (event) => {
|
setExportHistory((prev) => [{ id: result.jobId, status: "QUEUED" }, ...prev]);
|
||||||
const data = JSON.parse(event.data);
|
pushToast(t("toastExportQueued"), "info");
|
||||||
setExportHistory((prev) =>
|
const source = createEventSourceFor(`exports/${result.jobId}`, token);
|
||||||
prev.map((item) => (item.id === data.id ? { ...item, status: data.status, expiresAt: data.expiresAt, progress: data.progress } : item))
|
source.onmessage = async (event) => {
|
||||||
);
|
const data = JSON.parse(event.data);
|
||||||
if (data.status === "DONE") {
|
setExportHistory((prev) =>
|
||||||
const response = await downloadExport(token, result.jobId);
|
prev.map((item) => (item.id === data.id ? { ...item, status: data.status, expiresAt: data.expiresAt, progress: data.progress } : item))
|
||||||
const blob = await response.blob();
|
);
|
||||||
downloadFile(blob, `tenant-${tenant.id}.zip`);
|
if (data.status === "DONE") {
|
||||||
setExportStatus("done");
|
const response = await downloadExport(token, result.jobId);
|
||||||
source.close();
|
const blob = await response.blob();
|
||||||
setTimeout(() => setExportStatus("idle"), 1500);
|
downloadFile(blob, `tenant-${tenant.id}.zip`);
|
||||||
} else if (data.status === "FAILED") {
|
setExportStatus("done");
|
||||||
setExportStatus("failed");
|
pushToast(t("toastExportReady"), "success");
|
||||||
source.close();
|
source.close();
|
||||||
}
|
setTimeout(() => setExportStatus("idle"), 1500);
|
||||||
};
|
} else if (data.status === "FAILED") {
|
||||||
return;
|
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) => {
|
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;
|
||||||
await apiFetch(`/admin/tenants/${tenant.id}`, { method: "DELETE" }, token);
|
try {
|
||||||
setTenants((prev) => prev.filter((item) => item.id !== tenant.id));
|
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 toggleUser = async (user: User) => {
|
||||||
const result = await apiFetch(
|
try {
|
||||||
`/admin/users/${user.id}`,
|
const result = await apiFetch(
|
||||||
{ method: "PUT", body: JSON.stringify({ isActive: !user.isActive }) },
|
`/admin/users/${user.id}`,
|
||||||
token
|
{ method: "PUT", body: JSON.stringify({ isActive: !user.isActive }) },
|
||||||
);
|
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) => {
|
||||||
const result = await apiFetch(
|
try {
|
||||||
`/admin/accounts/${account.id}`,
|
const result = await apiFetch(
|
||||||
{ method: "PUT", body: JSON.stringify({ isActive: !account.isActive }) },
|
`/admin/accounts/${account.id}`,
|
||||||
token
|
{ method: "PUT", body: JSON.stringify({ isActive: !account.isActive }) },
|
||||||
);
|
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") => {
|
||||||
const result = await apiFetch(
|
try {
|
||||||
`/admin/users/${user.id}/role`,
|
const result = await apiFetch(
|
||||||
{ method: "PUT", body: JSON.stringify({ role }) },
|
`/admin/users/${user.id}/role`,
|
||||||
token
|
{ method: "PUT", body: JSON.stringify({ role }) },
|
||||||
);
|
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) => {
|
||||||
const result = await apiFetch(`/admin/impersonate/${user.id}`, { method: "POST" }, token);
|
try {
|
||||||
onImpersonate(result.token);
|
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 () => {
|
const resetPasswordForUser = async () => {
|
||||||
if (!resetUserId || resetPassword.length < 10) return;
|
if (!resetUserId || resetPassword.length < 10) return;
|
||||||
await apiFetch(`/admin/users/${resetUserId}/reset`, {
|
try {
|
||||||
method: "POST",
|
await apiFetch(`/admin/users/${resetUserId}/reset`, {
|
||||||
body: JSON.stringify({ password: resetPassword })
|
method: "POST",
|
||||||
}, token);
|
body: JSON.stringify({ password: resetPassword })
|
||||||
setResetUserId(null);
|
}, token);
|
||||||
setResetPassword("");
|
setResetUserId(null);
|
||||||
|
setResetPassword("");
|
||||||
|
pushToast(t("toastPasswordReset"), "success");
|
||||||
|
} catch (err) {
|
||||||
|
pushToast(getErrorMessage(err), "error");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelJob = async (job: Job) => {
|
const cancelJob = async (job: Job) => {
|
||||||
await apiFetch(`/admin/jobs/${job.id}/cancel`, { method: "POST" }, token);
|
try {
|
||||||
setJobs((prev) => prev.map((item) => (item.id === job.id ? { ...item, status: "CANCELED" } : item)));
|
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) => {
|
const retryJob = async (job: Job) => {
|
||||||
await apiFetch(`/admin/jobs/${job.id}/retry`, { method: "POST" }, token);
|
try {
|
||||||
loadAll().catch(() => undefined);
|
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) => {
|
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 () => {
|
||||||
await apiFetch("/admin/exports/purge", { method: "POST" }, token);
|
try {
|
||||||
loadAll().catch(() => undefined);
|
await apiFetch("/admin/exports/purge", { method: "POST" }, token);
|
||||||
|
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 () => {
|
||||||
await apiFetch(`/admin/exports/${item.id}`, { method: "DELETE" }, token);
|
try {
|
||||||
loadAll().catch(() => undefined);
|
await apiFetch(`/admin/exports/${item.id}`, { method: "DELETE" }, token);
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ?? {});
|
||||||
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) {
|
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();
|
||||||
|
|||||||
@@ -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 }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 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",
|
"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 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",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
<App />
|
<ToastProvider>
|
||||||
|
<App />
|
||||||
|
</ToastProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
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