From 4d24e3a6572d4ba1eb4b3fdd39d9cdf40768818e Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Mon, 18 May 2026 18:17:23 +0200 Subject: [PATCH] =?UTF-8?q?KI-AGENT:=20Erstzugriff=20und=20Mandanten-Grund?= =?UTF-8?q?daten=20f=C3=BCr=20Selfhosting=20erg=C3=A4nzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 9 + README.md | 11 + backend/src/index.ts | 2 + backend/src/modules/bootstrap.service.ts | 490 +++++++++++++++++++++++ backend/src/routes/admin.ts | 2 + docker-compose.selfhost.yml | 6 + frontend/components/TenantDropdown.vue | 4 +- 7 files changed, 521 insertions(+), 3 deletions(-) create mode 100644 backend/src/modules/bootstrap.service.ts diff --git a/.env.example b/.env.example index c456fe4..e46fdfc 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,15 @@ OPENAI_API_KEY=replace-this STIRLING_API_KEY=replace-this NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license +# Optionaler Erststart-Bootstrap. Wenn gesetzt, werden Admin, Mandant, +# Admin-Rolle und Grunddaten beim Backend-Start idempotent angelegt. +FEDEO_BOOTSTRAP_ADMIN_EMAIL=admin@example.com +FEDEO_BOOTSTRAP_ADMIN_PASSWORD=change-this-admin-password +FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME=Admin +FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer +FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen +FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN + # FEDEO Matrix-Kommunikation # # Diese Werte werden von docker-compose.yml gelesen, wenn das Profil "matrix" diff --git a/README.md b/README.md index a691a18..d9cf338 100644 --- a/README.md +++ b/README.md @@ -184,8 +184,17 @@ OPENAI_API_KEY=replace-this STIRLING_API_KEY=replace-this NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license + +FEDEO_BOOTSTRAP_ADMIN_EMAIL=admin@example.com +FEDEO_BOOTSTRAP_ADMIN_PASSWORD=change-this-admin-password +FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME=Admin +FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer +FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen +FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN ``` +Die `FEDEO_BOOTSTRAP_*`-Werte sind für den ersten Start gedacht. Wenn `FEDEO_BOOTSTRAP_ADMIN_EMAIL` und `FEDEO_BOOTSTRAP_ADMIN_PASSWORD` gesetzt sind, legt das Backend idempotent einen Admin-Benutzer, einen ersten Mandanten, eine Administrator-Rolle und grundlegende Stammdaten an. Nach erfolgreichem Erstzugriff solltest du das Bootstrap-Passwort aus der `.env` entfernen oder ändern. + ## Docker Compose mit optionaler S3-MinIO-Option Die Selfhost-Konfiguration liegt in `docker-compose.selfhost.yml`. Sie startet MinIO standardmäßig mit. Wenn du stattdessen AWS S3, Hetzner Object Storage, Backblaze B2 S3 oder einen anderen externen S3-Dienst nutzen willst, kannst du die Services `minio` und `createbuckets` entfernen und nur die entsprechenden S3-Umgebungsvariablen auf den externen Anbieter zeigen lassen. @@ -414,6 +423,8 @@ Erwartung: - Frontend liefert `200` oder `302` - Backend liefert JSON wie `{"status":"ok"}` +Wenn der Bootstrap aktiviert ist, kannst du dich danach mit `FEDEO_BOOTSTRAP_ADMIN_EMAIL` und `FEDEO_BOOTSTRAP_ADMIN_PASSWORD` anmelden. Die Mandantensperre wird über `locked` gesteuert; `hasActiveLicense` wird nicht mehr für den Selfhost-Zugriff ausgewertet. + ## Updates Bei neuen Versionen: diff --git a/backend/src/index.ts b/backend/src/index.ts index 221859f..628e111 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -56,6 +56,7 @@ import {sendMail} from "./utils/mailer"; import {loadSecrets, secrets} from "./utils/secrets"; import {initMailer} from "./utils/mailer" import {initS3} from "./utils/s3"; +import { runBootstrap } from "./modules/bootstrap.service"; //Services @@ -80,6 +81,7 @@ async function main() { await app.register(dayjsPlugin); await app.register(dbPlugin); await app.register(servicesPlugin); + await runBootstrap(app); app.addHook('preHandler', (req, reply, done) => { console.log(req.method) diff --git a/backend/src/modules/bootstrap.service.ts b/backend/src/modules/bootstrap.service.ts new file mode 100644 index 0000000..ee424e3 --- /dev/null +++ b/backend/src/modules/bootstrap.service.ts @@ -0,0 +1,490 @@ +import { FastifyInstance } from "fastify" +import { and, eq } from "drizzle-orm" +import bcrypt from "bcrypt" + +import { + accounts, + authProfiles, + authRoles, + authRolePermissions, + authTenantUsers, + authUserRoles, + authUsers, + branches, + filetags, + folders, + productcategories, + servicecategories, + taxTypes, + teams, + tenants, + texttemplates, + units, +} from "../../db/schema" + +const adminPermissions = [ + "mcp.tokens.write", + "staff.time.read_all", + "masterdata.customers.read", + "masterdata.vendors.read", + "masterdata.contacts.read", + "masterdata.products.read", + "masterdata.services.read", + "masterdata.cost_centres.read", + "masterdata.branches.read", + "masterdata.teams.read", + "masterdata.vehicles.read", + "masterdata.inventory_items.read", + "masterdata.units.read", + "accounting.outgoing_documents.read", + "accounting.outgoing_documents.write", + "accounting.accounts.read", + "accounting.incoming_invoices.read", + "accounting.incoming_invoices.write", + "accounting.bank.read", + "accounting.statement_allocations.read", + "organisation.customers.read", + "organisation.projects.read", + "organisation.plants.read", + "organisation.events.read", + "organisation.tasks.read", + "organisation.tasks.write", +] + +const defaultUnits = [ + { name: "Stück", single: "Stück", multiple: "Stück", short: "Stk.", step: "1" }, + { name: "Stunde", single: "Stunde", multiple: "Stunden", short: "Std.", step: "0.25" }, + { name: "Pauschale", single: "Pauschale", multiple: "Pauschalen", short: "Psch.", step: "1" }, + { name: "Meter", single: "Meter", multiple: "Meter", short: "m", step: "0.1" }, +] + +const defaultTaxTypes = [ + { label: "Umsatzsteuer 19%", percentage: 19 }, + { label: "Umsatzsteuer 7%", percentage: 7 }, + { label: "Steuerfrei", percentage: 0 }, +] + +const defaultAccounts = [ + { number: "8400", label: "Erlöse 19% USt", accountChart: "skr03" }, + { number: "8300", label: "Erlöse 7% USt", accountChart: "skr03" }, + { number: "1200", label: "Bank", accountChart: "skr03" }, + { number: "1000", label: "Kasse", accountChart: "skr03" }, + { number: "1400", label: "Forderungen aus Lieferungen und Leistungen", accountChart: "skr03" }, + { number: "1600", label: "Verbindlichkeiten aus Lieferungen und Leistungen", accountChart: "skr03" }, +] + +async function ensureGlobalDefaults(server: FastifyInstance, userId: string) { + for (const unit of defaultUnits) { + const existing = await server.db.select({ id: units.id }).from(units).where(eq(units.name, unit.name)).limit(1) + if (!existing.length) await server.db.insert(units).values(unit) + } + + for (const taxType of defaultTaxTypes) { + const existing = await server.db + .select({ id: taxTypes.id }) + .from(taxTypes) + .where(eq(taxTypes.percentage, taxType.percentage)) + .limit(1) + + if (!existing.length) { + await server.db.insert(taxTypes).values({ + ...taxType, + updatedAt: new Date(), + updatedBy: userId, + }) + } + } + + for (const account of defaultAccounts) { + const existing = await server.db + .select({ id: accounts.id }) + .from(accounts) + .where(and(eq(accounts.accountChart, account.accountChart), eq(accounts.number, account.number))) + .limit(1) + + if (!existing.length) { + await server.db.insert(accounts).values({ + ...account, + description: "FEDEO Standardkonto", + }) + } + } +} + +async function ensureTenantFileDefaults(server: FastifyInstance, tenantId: number, userId: string) { + const currentYear = new Date().getFullYear() + const timestamp = new Date() + + const tagDefaults = [ + { name: "Rechnungen", color: "#16a34a", createdDocumentType: "invoices" }, + { name: "Angebote", color: "#2563eb", createdDocumentType: "quotes" }, + { name: "Auftragsbestätigungen", color: "#7c3aed", createdDocumentType: "confirmationOrders" }, + { name: "Lieferscheine", color: "#ea580c", createdDocumentType: "deliveryNotes" }, + { name: "Eingangsrechnungen", color: "#dc2626", incomingDocumentType: "invoices" }, + { name: "Mahnungen", color: "#b91c1c", incomingDocumentType: "reminders" }, + ] + + for (const tag of tagDefaults) { + const existing = await server.db + .select({ id: filetags.id }) + .from(filetags) + .where(and(eq(filetags.tenant, tenantId), eq(filetags.name, tag.name))) + .limit(1) + + if (!existing.length) { + await server.db.insert(filetags).values({ + tenant: tenantId, + ...tag, + }) + } + } + + const allTags = await server.db.select().from(filetags).where(eq(filetags.tenant, tenantId)) + const tagByCreatedType = new Map(allTags.map((tag) => [tag.createdDocumentType, tag.id])) + const tagByIncomingType = new Map(allTags.map((tag) => [tag.incomingDocumentType, tag.id])) + + const rootFolders = [ + { name: "Ausgangsrechnungen", function: "yearSubCategory" as const, icon: "i-heroicons-document-text" }, + { name: "Angebote", function: "yearSubCategory" as const, icon: "i-heroicons-document-duplicate" }, + { name: "Auftragsbestätigungen", function: "yearSubCategory" as const, icon: "i-heroicons-clipboard-document-check" }, + { name: "Lieferscheine", function: "yearSubCategory" as const, icon: "i-heroicons-truck" }, + { name: "Eingangsrechnungen", function: "yearSubCategory" as const, icon: "i-heroicons-inbox-arrow-down" }, + { name: "Belege Bankeinzahlung", function: "yearSubCategory" as const, icon: "i-heroicons-banknotes" }, + ] + + for (const folder of rootFolders) { + const existing = await server.db + .select({ id: folders.id }) + .from(folders) + .where(and(eq(folders.tenant, tenantId), eq(folders.name, folder.name))) + .limit(1) + + if (!existing.length) { + await server.db.insert(folders).values({ + tenant: tenantId, + name: folder.name, + function: folder.function, + icon: folder.icon, + isSystemUsed: true, + updatedAt: timestamp, + updatedBy: userId, + }) + } + } + + const allFolders = await server.db.select().from(folders).where(eq(folders.tenant, tenantId)) + const rootFolderByName = new Map(allFolders.filter((folder) => !folder.parent).map((folder) => [folder.name, folder.id])) + + const yearFolders = [ + { + parentName: "Ausgangsrechnungen", + function: "invoices" as const, + icon: "i-heroicons-document-text", + standardFiletype: tagByCreatedType.get("invoices"), + }, + { + parentName: "Angebote", + function: "quotes" as const, + icon: "i-heroicons-document-duplicate", + standardFiletype: tagByCreatedType.get("quotes"), + }, + { + parentName: "Auftragsbestätigungen", + function: "confirmationOrders" as const, + icon: "i-heroicons-clipboard-document-check", + standardFiletype: tagByCreatedType.get("confirmationOrders"), + }, + { + parentName: "Lieferscheine", + function: "deliveryNotes" as const, + icon: "i-heroicons-truck", + standardFiletype: tagByCreatedType.get("deliveryNotes"), + }, + { + parentName: "Eingangsrechnungen", + function: "incomingInvoices" as const, + icon: "i-heroicons-inbox-arrow-down", + standardFiletype: tagByIncomingType.get("invoices"), + }, + { + parentName: "Belege Bankeinzahlung", + function: "deposit" as const, + icon: "i-heroicons-banknotes", + }, + ] + + for (const folder of yearFolders) { + const parent = rootFolderByName.get(folder.parentName) + if (!parent) continue + + const existing = await server.db + .select({ id: folders.id }) + .from(folders) + .where(and(eq(folders.tenant, tenantId), eq(folders.parent, parent), eq(folders.year, currentYear))) + .limit(1) + + if (!existing.length) { + await server.db.insert(folders).values({ + tenant: tenantId, + name: String(currentYear), + parent, + function: folder.function, + year: currentYear, + icon: folder.icon, + standardFiletype: folder.standardFiletype, + standardFiletypeIsOptional: false, + isSystemUsed: true, + updatedAt: timestamp, + updatedBy: userId, + }) + } + } +} + +export async function ensureTenantBaseData(server: FastifyInstance, tenantId: number, adminUserId: string) { + await ensureGlobalDefaults(server, adminUserId) + await ensureTenantFileDefaults(server, tenantId, adminUserId) + + const [adminRole] = await server.db + .select({ id: authRoles.id }) + .from(authRoles) + .where(and(eq(authRoles.tenant_id, tenantId), eq(authRoles.name, "Administrator"))) + .limit(1) + + let adminRoleId = adminRole?.id + if (!adminRoleId) { + const [createdRole] = await server.db + .insert(authRoles) + .values({ + name: "Administrator", + description: "Vollzugriff für die Administration dieses Mandanten", + tenant_id: tenantId, + created_by: adminUserId, + }) + .returning({ id: authRoles.id }) + + adminRoleId = createdRole.id + } + + for (const permission of adminPermissions) { + const existing = await server.db + .select() + .from(authRolePermissions) + .where(and(eq(authRolePermissions.role_id, adminRoleId), eq(authRolePermissions.permission, permission))) + .limit(1) + + if (!existing.length) { + await server.db.insert(authRolePermissions).values({ + role_id: adminRoleId, + permission, + }) + } + } + + const membership = await server.db + .select() + .from(authTenantUsers) + .where(and(eq(authTenantUsers.tenant_id, tenantId), eq(authTenantUsers.user_id, adminUserId))) + .limit(1) + + if (!membership.length) { + await server.db.insert(authTenantUsers).values({ + tenant_id: tenantId, + user_id: adminUserId, + created_by: adminUserId, + }) + } + + const roleAssignment = await server.db + .select() + .from(authUserRoles) + .where(and( + eq(authUserRoles.tenant_id, tenantId), + eq(authUserRoles.user_id, adminUserId), + eq(authUserRoles.role_id, adminRoleId), + )) + .limit(1) + + if (!roleAssignment.length) { + await server.db.insert(authUserRoles).values({ + tenant_id: tenantId, + user_id: adminUserId, + role_id: adminRoleId, + created_by: adminUserId, + }) + } + + const profile = await server.db + .select() + .from(authProfiles) + .where(and(eq(authProfiles.tenant_id, tenantId), eq(authProfiles.user_id, adminUserId))) + .limit(1) + + if (!profile.length) { + await server.db.insert(authProfiles).values({ + tenant_id: tenantId, + user_id: adminUserId, + first_name: process.env.FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME || "Admin", + last_name: process.env.FEDEO_BOOTSTRAP_ADMIN_LAST_NAME || "Benutzer", + email: process.env.FEDEO_BOOTSTRAP_ADMIN_EMAIL?.trim().toLowerCase(), + active: true, + }) + } + + const branch = await server.db + .select({ id: branches.id }) + .from(branches) + .where(and(eq(branches.tenant, tenantId), eq(branches.name, "Hauptstandort"))) + .limit(1) + + let branchId = branch[0]?.id + if (!branchId) { + const [createdBranch] = await server.db.insert(branches).values({ + tenant: tenantId, + name: "Hauptstandort", + number: "001", + description: "Standardstandort", + updatedAt: new Date(), + updatedBy: adminUserId, + }).returning({ id: branches.id }) + branchId = createdBranch.id + } + + const team = await server.db + .select({ id: teams.id }) + .from(teams) + .where(and(eq(teams.tenant, tenantId), eq(teams.name, "Standardteam"))) + .limit(1) + + if (!team.length) { + await server.db.insert(teams).values({ + tenant: tenantId, + name: "Standardteam", + description: "Automatisch angelegtes Standardteam", + branch: branchId, + updatedAt: new Date(), + updatedBy: adminUserId, + }) + } + + const defaultProductCategory = await server.db + .select({ id: productcategories.id }) + .from(productcategories) + .where(and(eq(productcategories.tenant, tenantId), eq(productcategories.name, "Standard"))) + .limit(1) + + if (!defaultProductCategory.length) { + await server.db.insert(productcategories).values({ + tenant: tenantId, + name: "Standard", + description: "Standardkategorie", + updatedAt: new Date(), + updatedBy: adminUserId, + }) + } + + const defaultServiceCategory = await server.db + .select({ id: servicecategories.id }) + .from(servicecategories) + .where(and(eq(servicecategories.tenant, tenantId), eq(servicecategories.name, "Standard"))) + .limit(1) + + if (!defaultServiceCategory.length) { + await server.db.insert(servicecategories).values({ + tenant: tenantId, + name: "Standard", + description: "Standardkategorie", + updated_at: new Date(), + updated_by: adminUserId, + }) + } + + const templateDefaults = [ + { name: "Standard Einleitung", pos: "startText" as const, text: "

vielen Dank für Ihre Anfrage.

" }, + { name: "Standard Schluss", pos: "endText" as const, text: "

Mit freundlichen Grüßen

" }, + ] + + for (const template of templateDefaults) { + const existing = await server.db + .select({ id: texttemplates.id }) + .from(texttemplates) + .where(and(eq(texttemplates.tenant, tenantId), eq(texttemplates.name, template.name))) + .limit(1) + + if (!existing.length) { + await server.db.insert(texttemplates).values({ + tenant: tenantId, + name: template.name, + text: template.text, + pos: template.pos, + default: true, + updatedAt: new Date(), + updatedBy: adminUserId, + }) + } + } +} + +export async function runBootstrap(server: FastifyInstance) { + const email = process.env.FEDEO_BOOTSTRAP_ADMIN_EMAIL?.trim().toLowerCase() + const password = process.env.FEDEO_BOOTSTRAP_ADMIN_PASSWORD + + if (!email && !password) return + if (!email || !password) { + throw new Error("FEDEO_BOOTSTRAP_ADMIN_EMAIL und FEDEO_BOOTSTRAP_ADMIN_PASSWORD müssen gemeinsam gesetzt sein") + } + + const [existingUser] = await server.db + .select() + .from(authUsers) + .where(eq(authUsers.email, email)) + .limit(1) + + let adminUser = existingUser + if (!adminUser) { + const [createdUser] = await server.db.insert(authUsers).values({ + email, + passwordHash: await bcrypt.hash(password, 10), + is_admin: true, + multiTenant: true, + must_change_password: false, + updatedAt: new Date(), + }).returning() + + adminUser = createdUser + console.log(`✅ Bootstrap-Admin angelegt: ${email}`) + } else if (!adminUser.is_admin) { + const [updatedUser] = await server.db.update(authUsers).set({ + is_admin: true, + updatedAt: new Date(), + }).where(eq(authUsers.id, adminUser.id)).returning() + + adminUser = updatedUser + console.log(`✅ Bootstrap-Adminrechte gesetzt: ${email}`) + } + + const tenantName = process.env.FEDEO_BOOTSTRAP_TENANT_NAME?.trim() || "FEDEO" + const tenantShort = process.env.FEDEO_BOOTSTRAP_TENANT_SHORT?.trim() || "FEDEO" + + const [existingTenant] = await server.db + .select() + .from(tenants) + .where(eq(tenants.short, tenantShort)) + .limit(1) + + let tenant = existingTenant + if (!tenant) { + const [createdTenant] = await server.db.insert(tenants).values({ + name: tenantName, + short: tenantShort, + updatedAt: new Date(), + updatedBy: adminUser.id, + }).returning() + + tenant = createdTenant + console.log(`✅ Bootstrap-Mandant angelegt: ${tenant.name}`) + } + + await ensureTenantBaseData(server, tenant.id, adminUser.id) + console.log("✅ Bootstrap-Grunddaten geprüft") +} diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 0b05fc6..9f52e7e 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -14,6 +14,7 @@ import { } from "../../db/schema"; import { generateRandomPassword, hashPassword } from "../utils/password"; import { sendMail } from "../utils/mailer"; +import { ensureTenantBaseData } from "../modules/bootstrap.service"; export default async function adminRoutes(server: FastifyInstance) { const deriveNameFromEmail = (email: string) => { @@ -825,6 +826,7 @@ export default async function adminRoutes(server: FastifyInstance) { }); await createTenantSeeds(createdTenant.id, currentUser.id); + await ensureTenantBaseData(server, createdTenant.id, currentUser.id); return { tenant: createdTenant }; } catch (err) { diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml index 5a506e9..61a44c4 100644 --- a/docker-compose.selfhost.yml +++ b/docker-compose.selfhost.yml @@ -124,6 +124,12 @@ services: DOKUBOX_IMAP_PASSWORD: ${DOKUBOX_IMAP_PASSWORD} OPENAI_API_KEY: ${OPENAI_API_KEY} STIRLING_API_KEY: ${STIRLING_API_KEY} + FEDEO_BOOTSTRAP_ADMIN_EMAIL: ${FEDEO_BOOTSTRAP_ADMIN_EMAIL:-} + FEDEO_BOOTSTRAP_ADMIN_PASSWORD: ${FEDEO_BOOTSTRAP_ADMIN_PASSWORD:-} + FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME: ${FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME:-Admin} + FEDEO_BOOTSTRAP_ADMIN_LAST_NAME: ${FEDEO_BOOTSTRAP_ADMIN_LAST_NAME:-Benutzer} + FEDEO_BOOTSTRAP_TENANT_NAME: ${FEDEO_BOOTSTRAP_TENANT_NAME:-FEDEO} + FEDEO_BOOTSTRAP_TENANT_SHORT: ${FEDEO_BOOTSTRAP_TENANT_SHORT:-FEDEO} labels: - traefik.enable=true - traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`) diff --git a/frontend/components/TenantDropdown.vue b/frontend/components/TenantDropdown.vue index c569351..4436a20 100644 --- a/frontend/components/TenantDropdown.vue +++ b/frontend/components/TenantDropdown.vue @@ -14,9 +14,7 @@ const tenantInitials = computed(() => { .join('') || 'M' }) -const activeTenants = computed(() => - auth.tenants.filter((tenant) => tenant.hasActiveLicense) -) +const activeTenants = computed(() => auth.tenants) const tenantItems = computed(() => [ activeTenants.value.map((tenant) => ({