Admin UI abtrennen + google settings in gui + UI enhancement
This commit is contained in:
24
backend/node_modules/.prisma/client/edge.js
generated
vendored
24
backend/node_modules/.prisma/client/edge.js
generated
vendored
File diff suppressed because one or more lines are too long
10
backend/node_modules/.prisma/client/index-browser.js
generated
vendored
10
backend/node_modules/.prisma/client/index-browser.js
generated
vendored
@@ -257,6 +257,13 @@ exports.Prisma.CleanupJobEventScalarFieldEnum = {
|
||||
createdAt: 'createdAt'
|
||||
};
|
||||
|
||||
exports.Prisma.AppSettingScalarFieldEnum = {
|
||||
id: 'id',
|
||||
key: 'key',
|
||||
value: 'value',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
@@ -324,7 +331,8 @@ exports.Prisma.ModelName = {
|
||||
RuleAction: 'RuleAction',
|
||||
CleanupJob: 'CleanupJob',
|
||||
UnsubscribeAttempt: 'UnsubscribeAttempt',
|
||||
CleanupJobEvent: 'CleanupJobEvent'
|
||||
CleanupJobEvent: 'CleanupJobEvent',
|
||||
AppSetting: 'AppSetting'
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
1087
backend/node_modules/.prisma/client/index.d.ts
generated
vendored
1087
backend/node_modules/.prisma/client/index.d.ts
generated
vendored
File diff suppressed because it is too large
Load Diff
24
backend/node_modules/.prisma/client/index.js
generated
vendored
24
backend/node_modules/.prisma/client/index.js
generated
vendored
File diff suppressed because one or more lines are too long
2
backend/node_modules/.prisma/client/package.json
generated
vendored
2
backend/node_modules/.prisma/client/package.json
generated
vendored
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "prisma-client-6c67176f7022092b3b8f46007c6286b76456763ea6fcd4c80a580e5070b636e6",
|
||||
"name": "prisma-client-0dfa452a25e24864bcf3f498cd8b34074b00c8171bdce93526b6a0ab38135aa4",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"browser": "index-browser.js",
|
||||
|
||||
21
backend/node_modules/.prisma/client/schema.prisma
generated
vendored
21
backend/node_modules/.prisma/client/schema.prisma
generated
vendored
@@ -41,6 +41,13 @@ enum RuleConditionType {
|
||||
LIST_ID
|
||||
}
|
||||
|
||||
enum ExportStatus {
|
||||
QUEUED
|
||||
RUNNING
|
||||
DONE
|
||||
FAILED
|
||||
}
|
||||
|
||||
model Tenant {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
@@ -55,13 +62,6 @@ model Tenant {
|
||||
jobs CleanupJob[]
|
||||
}
|
||||
|
||||
enum ExportStatus {
|
||||
QUEUED
|
||||
RUNNING
|
||||
DONE
|
||||
FAILED
|
||||
}
|
||||
|
||||
model ExportJob {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
@@ -240,3 +240,10 @@ model CleanupJobEvent {
|
||||
|
||||
@@index([jobId])
|
||||
}
|
||||
|
||||
model AppSetting {
|
||||
id String @id @default(cuid())
|
||||
key String @unique
|
||||
value String
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
10
backend/node_modules/.prisma/client/wasm.js
generated
vendored
10
backend/node_modules/.prisma/client/wasm.js
generated
vendored
@@ -257,6 +257,13 @@ exports.Prisma.CleanupJobEventScalarFieldEnum = {
|
||||
createdAt: 'createdAt'
|
||||
};
|
||||
|
||||
exports.Prisma.AppSettingScalarFieldEnum = {
|
||||
id: 'id',
|
||||
key: 'key',
|
||||
value: 'value',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
@@ -324,7 +331,8 @@ exports.Prisma.ModelName = {
|
||||
RuleAction: 'RuleAction',
|
||||
CleanupJob: 'CleanupJob',
|
||||
UnsubscribeAttempt: 'UnsubscribeAttempt',
|
||||
CleanupJobEvent: 'CleanupJobEvent'
|
||||
CleanupJobEvent: 'CleanupJobEvent',
|
||||
AppSetting: 'AppSetting'
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "AppSetting" (
|
||||
"id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AppSetting_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AppSetting_key_key" ON "AppSetting"("key");
|
||||
@@ -41,6 +41,13 @@ enum RuleConditionType {
|
||||
LIST_ID
|
||||
}
|
||||
|
||||
enum ExportStatus {
|
||||
QUEUED
|
||||
RUNNING
|
||||
DONE
|
||||
FAILED
|
||||
}
|
||||
|
||||
model Tenant {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
@@ -55,13 +62,6 @@ model Tenant {
|
||||
jobs CleanupJob[]
|
||||
}
|
||||
|
||||
enum ExportStatus {
|
||||
QUEUED
|
||||
RUNNING
|
||||
DONE
|
||||
FAILED
|
||||
}
|
||||
|
||||
model ExportJob {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
@@ -240,3 +240,10 @@ model CleanupJobEvent {
|
||||
|
||||
@@index([jobId])
|
||||
}
|
||||
|
||||
model AppSetting {
|
||||
id String @id @default(cuid())
|
||||
key String @unique
|
||||
value String
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { queueCleanupJob, removeQueueJob, queueExportJob } from "../queue/queue.
|
||||
import { createReadStream } from "node:fs";
|
||||
import { access, unlink } from "node:fs/promises";
|
||||
import { cleanupExpiredExports } from "./exportCleanup.js";
|
||||
import { deleteSetting, listSettings, setSetting } from "./settings.js";
|
||||
|
||||
const roleSchema = z.object({
|
||||
role: z.enum(["USER", "ADMIN"])
|
||||
@@ -19,9 +20,53 @@ const resetSchema = z.object({
|
||||
password: z.string().min(10)
|
||||
});
|
||||
|
||||
const settingsSchema = z.object({
|
||||
settings: z.record(z.string(), z.string().nullable())
|
||||
});
|
||||
|
||||
const allowedSettings = ["google.client_id", "google.client_secret", "google.redirect_uri"] as const;
|
||||
|
||||
export async function adminRoutes(app: FastifyInstance) {
|
||||
app.addHook("preHandler", app.requireAdmin);
|
||||
|
||||
app.get("/settings", async () => {
|
||||
const keys = [...allowedSettings];
|
||||
const stored = await listSettings(keys);
|
||||
const envDefaults: Record<string, string | null> = {
|
||||
"google.client_id": process.env.GOOGLE_CLIENT_ID ?? null,
|
||||
"google.client_secret": process.env.GOOGLE_CLIENT_SECRET ?? null,
|
||||
"google.redirect_uri": process.env.GOOGLE_REDIRECT_URI ?? null
|
||||
};
|
||||
const settings = keys.reduce<Record<string, { value: string | null; source: "db" | "env" | "unset" }>>((acc, key) => {
|
||||
const dbValue = stored[key];
|
||||
if (dbValue !== null && dbValue !== undefined) {
|
||||
acc[key] = { value: dbValue, source: "db" };
|
||||
} else if (envDefaults[key]) {
|
||||
acc[key] = { value: envDefaults[key], source: "env" };
|
||||
} else {
|
||||
acc[key] = { value: null, source: "unset" };
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
return { settings };
|
||||
});
|
||||
|
||||
app.put("/settings", async (request) => {
|
||||
const input = settingsSchema.parse(request.body);
|
||||
const entries = Object.entries(input.settings);
|
||||
for (const [key, value] of entries) {
|
||||
if (!allowedSettings.includes(key as (typeof allowedSettings)[number])) continue;
|
||||
if (value === null || value.trim() === "") {
|
||||
await deleteSetting(key);
|
||||
} else {
|
||||
await setSetting(key, value);
|
||||
}
|
||||
}
|
||||
const keys = [...allowedSettings];
|
||||
const stored = await listSettings(keys);
|
||||
return { settings: stored };
|
||||
});
|
||||
|
||||
app.get("/tenants", async () => {
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
include: { _count: { select: { users: true, mailboxAccounts: true, jobs: true } } },
|
||||
@@ -156,7 +201,18 @@ export async function adminRoutes(app: FastifyInstance) {
|
||||
const tenant = await prisma.tenant.findUnique({ where: { id: params.id } });
|
||||
if (!tenant) return reply.code(404).send({ message: "Tenant not found" });
|
||||
|
||||
const exportJobs = await prisma.exportJob.findMany({ where: { tenantId: tenant.id } });
|
||||
for (const job of exportJobs) {
|
||||
if (!job.filePath) continue;
|
||||
try {
|
||||
await unlink(job.filePath);
|
||||
} catch {
|
||||
// ignore missing files
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.exportJob.deleteMany({ where: { tenantId: tenant.id } });
|
||||
const jobs = await tx.cleanupJob.findMany({ where: { tenantId: tenant.id } });
|
||||
const jobIds = jobs.map((job) => job.id);
|
||||
await tx.cleanupJobEvent.deleteMany({ where: { jobId: { in: jobIds } } });
|
||||
|
||||
27
backend/src/admin/settings.ts
Normal file
27
backend/src/admin/settings.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { prisma } from "../db.js";
|
||||
|
||||
export const getSetting = async (key: string) => {
|
||||
const setting = await prisma.appSetting.findUnique({ where: { key } });
|
||||
return setting?.value ?? null;
|
||||
};
|
||||
|
||||
export const setSetting = async (key: string, value: string) => {
|
||||
return prisma.appSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value }
|
||||
});
|
||||
};
|
||||
|
||||
export const listSettings = async (keys: string[]) => {
|
||||
const settings = await prisma.appSetting.findMany({ where: { key: { in: keys } } });
|
||||
const map = new Map(settings.map((s) => [s.key, s.value]));
|
||||
return keys.reduce<Record<string, string | null>>((acc, key) => {
|
||||
acc[key] = map.get(key) ?? null;
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
export const deleteSetting = async (key: string) => {
|
||||
await prisma.appSetting.deleteMany({ where: { key } });
|
||||
};
|
||||
@@ -9,7 +9,13 @@ const envSchema = z.object({
|
||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
GOOGLE_REDIRECT_URI: z.string().optional(),
|
||||
TRUST_PROXY: z.coerce.boolean().default(false)
|
||||
TRUST_PROXY: z.coerce.boolean().default(false),
|
||||
SEED_ENABLED: z.coerce.boolean().default(true),
|
||||
SEED_TENANT: z.string().default("Default Tenant"),
|
||||
SEED_TENANT_ID: z.string().default("seed-tenant"),
|
||||
SEED_ADMIN_EMAIL: z.string().email().optional(),
|
||||
SEED_ADMIN_PASSWORD: z.string().min(10).optional(),
|
||||
SEED_FORCE_PASSWORD_UPDATE: z.coerce.boolean().default(false)
|
||||
});
|
||||
|
||||
export type AppConfig = z.infer<typeof envSchema>;
|
||||
@@ -23,7 +29,13 @@ const parsed = envSchema.safeParse({
|
||||
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
|
||||
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
|
||||
GOOGLE_REDIRECT_URI: process.env.GOOGLE_REDIRECT_URI,
|
||||
TRUST_PROXY: process.env.TRUST_PROXY
|
||||
TRUST_PROXY: process.env.TRUST_PROXY,
|
||||
SEED_ENABLED: process.env.SEED_ENABLED,
|
||||
SEED_TENANT: process.env.SEED_TENANT,
|
||||
SEED_TENANT_ID: process.env.SEED_TENANT_ID,
|
||||
SEED_ADMIN_EMAIL: process.env.SEED_ADMIN_EMAIL,
|
||||
SEED_ADMIN_PASSWORD: process.env.SEED_ADMIN_PASSWORD,
|
||||
SEED_FORCE_PASSWORD_UPDATE: process.env.SEED_FORCE_PASSWORD_UPDATE
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -2,20 +2,21 @@ import { google } from "googleapis";
|
||||
import { MailboxAccount } from "@prisma/client";
|
||||
import { config } from "../config.js";
|
||||
import { prisma } from "../db.js";
|
||||
import { getSetting } from "../admin/settings.js";
|
||||
|
||||
const getOAuthClient = () => {
|
||||
if (!config.GOOGLE_CLIENT_ID || !config.GOOGLE_CLIENT_SECRET || !config.GOOGLE_REDIRECT_URI) {
|
||||
const getOAuthClient = async () => {
|
||||
const clientId = (await getSetting("google.client_id")) ?? config.GOOGLE_CLIENT_ID;
|
||||
const clientSecret = (await getSetting("google.client_secret")) ?? config.GOOGLE_CLIENT_SECRET;
|
||||
const redirectUri = (await getSetting("google.redirect_uri")) ?? config.GOOGLE_REDIRECT_URI;
|
||||
|
||||
if (!clientId || !clientSecret || !redirectUri) {
|
||||
throw new Error("Google OAuth config missing");
|
||||
}
|
||||
return new google.auth.OAuth2(
|
||||
config.GOOGLE_CLIENT_ID,
|
||||
config.GOOGLE_CLIENT_SECRET,
|
||||
config.GOOGLE_REDIRECT_URI
|
||||
);
|
||||
return new google.auth.OAuth2(clientId, clientSecret, redirectUri);
|
||||
};
|
||||
|
||||
export const getGmailAuthUrl = (state: string) => {
|
||||
const client = getOAuthClient();
|
||||
export const getGmailAuthUrl = async (state: string) => {
|
||||
const client = await getOAuthClient();
|
||||
return client.generateAuthUrl({
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
@@ -28,7 +29,7 @@ export const getGmailAuthUrl = (state: string) => {
|
||||
};
|
||||
|
||||
export const exchangeGmailCode = async (code: string) => {
|
||||
const client = getOAuthClient();
|
||||
const client = await getOAuthClient();
|
||||
const { tokens } = await client.getToken(code);
|
||||
return tokens;
|
||||
};
|
||||
@@ -49,7 +50,7 @@ export const gmailClientForAccount = async (account: MailboxAccount) => {
|
||||
throw new Error("Gmail OAuth not configured");
|
||||
}
|
||||
|
||||
const client = getOAuthClient();
|
||||
const client = await getOAuthClient();
|
||||
client.setCredentials({
|
||||
refresh_token: account.oauthRefreshToken ?? undefined,
|
||||
access_token: account.oauthAccessToken ?? undefined,
|
||||
|
||||
@@ -9,7 +9,7 @@ const urlSchema = z.object({ accountId: z.string() });
|
||||
export async function oauthRoutes(app: FastifyInstance) {
|
||||
app.addHook("preHandler", app.authenticate);
|
||||
|
||||
app.post("/gmail/url", async (request) => {
|
||||
app.post("/gmail/url", async (request, reply) => {
|
||||
const input = urlSchema.parse(request.body);
|
||||
const account = await prisma.mailboxAccount.findFirst({
|
||||
where: { id: input.accountId, tenantId: request.user.tenantId, provider: "GMAIL" }
|
||||
@@ -19,8 +19,12 @@ export async function oauthRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const state = `${account.id}:${request.user.tenantId}`;
|
||||
const url = getGmailAuthUrl(state);
|
||||
return { url };
|
||||
try {
|
||||
const url = await getGmailAuthUrl(state);
|
||||
return { url };
|
||||
} catch {
|
||||
return reply.code(400).send({ message: "Google OAuth config missing" });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/gmail/callback", async (request, reply) => {
|
||||
@@ -37,7 +41,12 @@ export async function oauthRoutes(app: FastifyInstance) {
|
||||
return reply.code(404).send({ message: "Account not found" });
|
||||
}
|
||||
|
||||
const tokens = await exchangeGmailCode(query.code);
|
||||
let tokens;
|
||||
try {
|
||||
tokens = await exchangeGmailCode(query.code);
|
||||
} catch {
|
||||
return reply.code(400).send({ message: "Google OAuth config missing" });
|
||||
}
|
||||
await storeGmailTokens(account.id, {
|
||||
access_token: tokens.access_token ?? undefined,
|
||||
refresh_token: tokens.refresh_token ?? undefined,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { queueRoutes } from "./queue/routes.js";
|
||||
import { rulesRoutes } from "./rules/routes.js";
|
||||
import { adminRoutes } from "./admin/routes.js";
|
||||
import { oauthRoutes } from "./mail/oauthRoutes.js";
|
||||
import { ensureSeedData } from "./seed.js";
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
@@ -26,7 +27,11 @@ const app = Fastify({
|
||||
trustProxy: config.TRUST_PROXY
|
||||
});
|
||||
|
||||
await app.register(cors, { origin: true });
|
||||
await app.register(cors, {
|
||||
origin: true,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Authorization", "Content-Type"]
|
||||
});
|
||||
await app.register(helmet);
|
||||
await app.register(jwt, { secret: config.JWT_SECRET });
|
||||
await app.register(authPlugin);
|
||||
@@ -48,6 +53,8 @@ await app.register(rulesRoutes, { prefix: "/rules" });
|
||||
await app.register(adminRoutes, { prefix: "/admin" });
|
||||
await app.register(oauthRoutes, { prefix: "/oauth" });
|
||||
|
||||
await ensureSeedData();
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
await app.listen({ port: config.PORT, host: "0.0.0.0" });
|
||||
|
||||
39
backend/src/seed.ts
Normal file
39
backend/src/seed.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import argon2 from "argon2";
|
||||
import { prisma } from "./db.js";
|
||||
import { config } from "./config.js";
|
||||
|
||||
export const ensureSeedData = async () => {
|
||||
if (!config.SEED_ENABLED) return;
|
||||
if (!config.SEED_ADMIN_EMAIL || !config.SEED_ADMIN_PASSWORD) return;
|
||||
|
||||
const existingUser = await prisma.user.findUnique({ where: { email: config.SEED_ADMIN_EMAIL } });
|
||||
if (existingUser) {
|
||||
const updates: { role?: "ADMIN"; isActive?: boolean; password?: string } = {};
|
||||
if (existingUser.role !== "ADMIN") updates.role = "ADMIN";
|
||||
if (!existingUser.isActive) updates.isActive = true;
|
||||
if (config.SEED_FORCE_PASSWORD_UPDATE) {
|
||||
updates.password = await argon2.hash(config.SEED_ADMIN_PASSWORD);
|
||||
}
|
||||
if (Object.keys(updates).length) {
|
||||
await prisma.user.update({ where: { id: existingUser.id }, data: updates });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const tenant = await prisma.tenant.upsert({
|
||||
where: { id: config.SEED_TENANT_ID },
|
||||
update: { name: config.SEED_TENANT, isActive: true },
|
||||
create: { id: config.SEED_TENANT_ID, name: config.SEED_TENANT, isActive: true }
|
||||
});
|
||||
|
||||
const hashed = await argon2.hash(config.SEED_ADMIN_PASSWORD);
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
email: config.SEED_ADMIN_EMAIL,
|
||||
password: hashed,
|
||||
role: "ADMIN",
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user