Admin UI abtrennen + google settings in gui + UI enhancement

This commit is contained in:
2026-01-22 19:59:39 +01:00
parent e280e4eadb
commit 0b53e47d4b
29 changed files with 2365 additions and 303 deletions

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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