From f125617af021e6f225aee2d6d808091e8e6abdd7 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Wed, 8 Apr 2026 18:52:04 +0200 Subject: [PATCH] Kundenportal arbeiten --- backend/src/routes/admin.ts | 256 ++++++++++ backend/src/routes/files.ts | 72 ++- backend/src/routes/resources/main.ts | 101 +++- backend/src/utils/resource.config.ts | 2 +- frontend/components/EntityShow.vue | 45 ++ frontend/composables/useAdmin.ts | 7 + frontend/middleware/auth.global.ts | 9 +- frontend/pages/createDocument/show/[id].vue | 46 +- frontend/pages/customer-portal.vue | 502 ++++++++++++++++++++ 9 files changed, 1017 insertions(+), 23 deletions(-) create mode 100644 frontend/pages/customer-portal.vue diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 1f663c9..4e61207 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -4,6 +4,7 @@ import { and, eq, inArray, isNull } from "drizzle-orm"; import { authTenantUsers, authProfiles, + customers, authRoles, authUserRoles, authUsers, @@ -12,6 +13,7 @@ import { tenants, } from "../../db/schema"; import { generateRandomPassword, hashPassword } from "../utils/password"; +import { sendMail } from "../utils/mailer"; export default async function adminRoutes(server: FastifyInstance) { const deriveNameFromEmail = (email: string) => { @@ -255,6 +257,33 @@ export default async function adminRoutes(server: FastifyInstance) { return currentUser; }; + const ensurePortalRoleForTenant = async (tenantId: number, createdBy: string) => { + const existingRoles = await server.db + .select({ + id: authRoles.id, + name: authRoles.name, + }) + .from(authRoles) + .where(eq(authRoles.tenant_id, tenantId)); + + const portalRole = existingRoles.find((role) => role.name === "Kundenportal"); + if (portalRole) return portalRole.id; + + const [createdRole] = await server.db + .insert(authRoles) + .values({ + name: "Kundenportal", + description: "Automatisch angelegte Rolle für eingeladene Kundenportal-Benutzer", + tenant_id: tenantId, + created_by: createdBy, + }) + .returning({ + id: authRoles.id, + }); + + return createdRole.id; + }; + // ------------------------------------------------------------- // GET /admin/overview // ------------------------------------------------------------- @@ -422,6 +451,233 @@ export default async function adminRoutes(server: FastifyInstance) { } }); + server.post("/admin/customers/:customerId/invite-portal-user", async (req, reply) => { + try { + const currentUser = await requireAdmin(req, reply); + if (!currentUser) return; + + const tenantId = Number(req.user?.tenant_id); + const { customerId } = req.params as { customerId: string }; + + if (!tenantId) { + return reply.code(400).send({ error: "No tenant selected" }); + } + + const [tenantRecord] = await server.db + .select({ + id: tenants.id, + name: tenants.name, + portalDomain: tenants.portalDomain, + }) + .from(tenants) + .where(eq(tenants.id, tenantId)) + .limit(1); + + const [customerRecord] = await server.db + .select() + .from(customers) + .where(and(eq(customers.id, Number(customerId)), eq(customers.tenant, tenantId))) + .limit(1); + + if (!customerRecord) { + return reply.code(404).send({ error: "Customer not found" }); + } + + const customerInfo = customerRecord.infoData && typeof customerRecord.infoData === "object" ? customerRecord.infoData as Record : {}; + const email = String(customerInfo.email || customerInfo.invoiceEmail || "").trim().toLowerCase(); + + if (!email) { + return reply.code(400).send({ error: "Customer has no email address" }); + } + + const generatedPassword = generateRandomPassword(14); + const passwordHash = await hashPassword(generatedPassword); + + const [existingUser] = await server.db + .select({ + id: authUsers.id, + email: authUsers.email, + is_admin: authUsers.is_admin, + }) + .from(authUsers) + .where(eq(authUsers.email, email)) + .limit(1); + + const derivedName = deriveNameFromEmail(email); + const firstName = customerRecord.firstname?.trim() || derivedName.first_name; + const lastName = customerRecord.lastname?.trim() || derivedName.last_name; + + let userId = existingUser?.id || null; + let createdNewUser = false; + + if (existingUser) { + const [existingProfile] = await server.db + .select({ + id: authProfiles.id, + customer_for_portal: authProfiles.customer_for_portal, + }) + .from(authProfiles) + .where(and( + eq(authProfiles.user_id, existingUser.id), + eq(authProfiles.tenant_id, tenantId) + )) + .limit(1); + + if (existingUser.is_admin) { + return reply.code(409).send({ error: "Email address is already used by an admin user" }); + } + + if (!existingProfile) { + return reply.code(409).send({ error: "Email address is already used by another user" }); + } + + if (existingProfile.customer_for_portal && existingProfile.customer_for_portal !== customerRecord.id) { + return reply.code(409).send({ error: "Email address is already assigned to another portal customer" }); + } + + await server.db + .update(authUsers) + .set({ + passwordHash, + must_change_password: true, + multiTenant: false, + updatedAt: new Date(), + }) + .where(eq(authUsers.id, existingUser.id)); + + userId = existingUser.id; + } else { + const [createdUser] = await server.db + .insert(authUsers) + .values({ + email, + passwordHash, + is_admin: false, + multiTenant: false, + must_change_password: true, + updatedAt: new Date(), + }) + .returning({ + id: authUsers.id, + }); + + userId = createdUser.id; + createdNewUser = true; + } + + const portalRoleId = await ensurePortalRoleForTenant(tenantId, currentUser.id); + + const existingMemberships = await server.db + .select() + .from(authTenantUsers) + .where(and( + eq(authTenantUsers.user_id, userId!), + eq(authTenantUsers.tenant_id, tenantId) + )) + .limit(1); + + if (!existingMemberships.length) { + await server.db + .insert(authTenantUsers) + .values({ + tenant_id: tenantId, + user_id: userId!, + created_by: currentUser.id, + }); + } + + const existingPortalRoleAssignment = await server.db + .select() + .from(authUserRoles) + .where(and( + eq(authUserRoles.user_id, userId!), + eq(authUserRoles.tenant_id, tenantId), + eq(authUserRoles.role_id, portalRoleId) + )) + .limit(1); + + if (!existingPortalRoleAssignment.length) { + await server.db + .insert(authUserRoles) + .values({ + user_id: userId!, + tenant_id: tenantId, + role_id: portalRoleId, + created_by: currentUser.id, + }); + } + + const [existingTenantProfile] = await server.db + .select({ + id: authProfiles.id, + user_id: authProfiles.user_id, + customer_for_portal: authProfiles.customer_for_portal, + }) + .from(authProfiles) + .where(and( + eq(authProfiles.user_id, userId!), + eq(authProfiles.tenant_id, tenantId) + )) + .limit(1); + + if (existingTenantProfile) { + await server.db + .update(authProfiles) + .set({ + first_name: firstName, + last_name: lastName, + email, + customer_for_portal: customerRecord.id, + active: true, + }) + .where(eq(authProfiles.id, existingTenantProfile.id)); + } else { + await server.db + .insert(authProfiles) + .values({ + user_id: userId!, + tenant_id: tenantId, + first_name: firstName, + last_name: lastName, + email, + customer_for_portal: customerRecord.id, + active: true, + }); + } + + const portalUrl = tenantRecord?.portalDomain ? `https://${tenantRecord.portalDomain}/login` : null; + + const mailResult = await sendMail( + email, + `FEDEO | Einladung ins Kundenportal`, + ` +

Hallo${customerRecord.name ? ` ${customerRecord.name}` : ""},

+

für Sie wurde ein Zugang zum FEDEO Kundenportal eingerichtet.

+

E-Mail: ${email}

+

Initialpasswort: ${generatedPassword}

+

Bitte ändern Sie dieses Passwort direkt nach dem ersten Login.

+ ${portalUrl ? `

Login: ${portalUrl}

` : ""} +

Viele Grüße
${tenantRecord?.name || "FEDEO"}

+ ` + ); + + if (!mailResult.success) { + return reply.code(500).send({ error: "Invitation email could not be sent" }); + } + + return { + success: true, + createdNewUser, + email, + initialPassword: generatedPassword, + portalUrl, + }; + } catch (err) { + console.error("ERROR /admin/customers/:customerId/invite-portal-user:", err); + return reply.code(500).send({ error: "Internal Server Error" }); + } + }); + // ------------------------------------------------------------- // POST /admin/tenants // ------------------------------------------------------------- diff --git a/backend/src/routes/files.ts b/backend/src/routes/files.ts index b4986ff..cff9141 100644 --- a/backend/src/routes/files.ts +++ b/backend/src/routes/files.ts @@ -10,7 +10,9 @@ import { secrets } from "../utils/secrets" import { saveFile } from "../utils/files" import { eq, inArray } from "drizzle-orm" +import { and } from "drizzle-orm" import { + authProfiles, files, createddocuments, customers @@ -18,6 +20,55 @@ import { export default async function fileRoutes(server: FastifyInstance) { + const getPortalCustomerId = async (req: any) => { + const tenantId = req.user?.tenant_id + const userId = req.user?.user_id + + if (!tenantId || !userId) return null + + const [profile] = await server.db + .select({ customer_for_portal: authProfiles.customer_for_portal }) + .from(authProfiles) + .where(and( + eq(authProfiles.tenant_id, tenantId), + eq(authProfiles.user_id, userId) + )) + .limit(1) + + return profile?.customer_for_portal || null + } + + const loadSingleFileForRequest = async (req: any, id: string) => { + const tenantId = req.user?.tenant_id + if (!tenantId) return null + + const portalCustomerId = await getPortalCustomerId(req) + + if (!portalCustomerId) { + const rows = await server.db + .select() + .from(files) + .where(and(eq(files.id, id), eq(files.tenant, tenantId))) + + return rows[0] || null + } + + const rows = await server.db + .select({ + file: files, + }) + .from(files) + .leftJoin(createddocuments, eq(files.createddocument, createddocuments.id)) + .where(and( + eq(files.id, id), + eq(files.tenant, tenantId), + eq(createddocuments.customer, portalCustomerId), + eq(createddocuments.availableInPortal, true) + )) + .limit(1) + + return rows[0]?.file || null + } // ------------------------------------------------------------- // MULTIPART INIT @@ -80,12 +131,7 @@ export default async function fileRoutes(server: FastifyInstance) { // 🔹 EINZELNE DATEI if (id) { - const rows = await server.db - .select() - .from(files) - .where(eq(files.id, id)) - - const file = rows[0] + const file = await loadSingleFileForRequest(req, id) if (!file) return reply.code(404).send({ error: "Not found" }) return file @@ -135,12 +181,7 @@ export default async function fileRoutes(server: FastifyInstance) { // 1️⃣ SINGLE DOWNLOAD // ------------------------------------------------- if (id) { - const rows = await server.db - .select() - .from(files) - .where(eq(files.id, id)) - - const file = rows[0] + const file = await loadSingleFileForRequest(req, id) if (!file) return reply.code(404).send({ error: "File not found" }) const command = new GetObjectCommand({ @@ -217,12 +258,7 @@ export default async function fileRoutes(server: FastifyInstance) { // SINGLE FILE PRESIGNED URL // ------------------------------------------------- if (id) { - const rows = await server.db - .select() - .from(files) - .where(eq(files.id, id)) - - const file = rows[0] + const file = await loadSingleFileForRequest(req, id) if (!file) return reply.code(404).send({ error: "Not found" }) const url = await getSignedUrl( diff --git a/backend/src/routes/resources/main.ts b/backend/src/routes/resources/main.ts index 62d5f87..09e6115 100644 --- a/backend/src/routes/resources/main.ts +++ b/backend/src/routes/resources/main.ts @@ -11,6 +11,7 @@ import { sql, } from "drizzle-orm" +import { authProfiles } from "../../../db/schema"; import { resourceConfig } from "../../utils/resource.config"; import { useNextNumberRangeNumber } from "../../utils/functions"; import { getHistoryEntityLabel, insertHistoryItem } from "../../utils/history"; @@ -18,6 +19,9 @@ import { diffObjects } from "../../utils/diff"; import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service"; import { decrypt, encrypt } from "../../utils/crypt"; +const PORTAL_ALLOWED_RESOURCES = new Set(["customers", "contracts", "createddocuments"]) +const PORTAL_VISIBLE_DOCUMENT_TYPES = ["invoices", "advanceInvoices", "cancellationInvoices"] + // ------------------------------------------------------------- // SQL Suche auf mehreren Feldern (Haupttabelle + Relationen) // ------------------------------------------------------------- @@ -130,6 +134,65 @@ function applyResourceWhereFilters(resource: string, table: any, whereCond: any) return whereCond } +async function getPortalCustomerId(server: FastifyInstance, req: any) { + const tenantId = req.user?.tenant_id + const userId = req.user?.user_id + + if (!tenantId || !userId) return null + + const [profile] = await server.db + .select({ customer_for_portal: authProfiles.customer_for_portal }) + .from(authProfiles) + .where(and( + eq(authProfiles.tenant_id, tenantId), + eq(authProfiles.user_id, userId) + )) + .limit(1) + + return profile?.customer_for_portal || null +} + +function applyPortalScope(resource: string, table: any, whereCond: any, portalCustomerId: number | null) { + if (!portalCustomerId) return whereCond + + if (!PORTAL_ALLOWED_RESOURCES.has(resource)) { + return null + } + + if (resource === "customers") { + return and(whereCond, eq(table.id, portalCustomerId)) + } + + if (resource === "contracts") { + return and(whereCond, eq(table.customer, portalCustomerId)) + } + + if (resource === "createddocuments") { + return and( + whereCond, + eq(table.customer, portalCustomerId), + eq(table.availableInPortal, true), + inArray(table.type, PORTAL_VISIBLE_DOCUMENT_TYPES) + ) + } + + return whereCond +} + +function sanitizePortalCustomerUpdate(payload: Record) { + const nextInfoData = payload.infoData && typeof payload.infoData === "object" ? payload.infoData : {} + + return { + name: payload.name, + firstname: payload.firstname, + lastname: payload.lastname, + salutation: payload.salutation, + title: payload.title, + nameAddition: payload.nameAddition, + infoData: nextInfoData, + } +} + function getTenantColumn(resource: string, table: any) { const config = resourceConfig[resource] const tenantKey = config?.tenantKey || "tenant" @@ -271,11 +334,16 @@ export default async function resourceRoutes(server: FastifyInstance) { if (!config) { return reply.code(404).send({ error: "Unknown resource" }) } + const portalCustomerId = await getPortalCustomerId(server, req) + if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) { + return reply.code(403).send({ error: "Forbidden" }) + } const table = config.table const tenantColumn = getTenantColumn(resource, table) let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined whereCond = applyResourceWhereFilters(resource, table, whereCond) + whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId) let q = server.db.select().from(table).$dynamic() const searchCols: any[] = (config.searchColumns || []).map(c => table[c]) @@ -380,6 +448,10 @@ export default async function resourceRoutes(server: FastifyInstance) { if (!config) { return reply.code(404).send({ error: "Unknown resource" }); } + const portalCustomerId = await getPortalCustomerId(server, req) + if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) { + return reply.code(403).send({ error: "Forbidden" }) + } const table = config.table; const { queryConfig } = req; @@ -389,6 +461,7 @@ export default async function resourceRoutes(server: FastifyInstance) { const tenantColumn = getTenantColumn(resource, table); let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined; whereCond = applyResourceWhereFilters(resource, table, whereCond) + whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId) const searchCols: any[] = (config.searchColumns || []).map(c => table[c]); const debugSearchColumnNames: string[] = [...(config.searchColumns || [])]; const parsedFilters: Array<{ key: string; value: any }> = [] @@ -489,6 +562,7 @@ export default async function resourceRoutes(server: FastifyInstance) { } let distinctWhereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond) + distinctWhereCond = applyPortalScope(resource, table, distinctWhereCond, portalCustomerId) if (search) { const searchCond = buildSearchCondition(searchCols, search.trim()) @@ -570,10 +644,15 @@ export default async function resourceRoutes(server: FastifyInstance) { if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) const { resource, no_relations } = req.params as { resource: string, no_relations?: boolean } + const portalCustomerId = await getPortalCustomerId(server, req) + if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) { + return reply.code(403).send({ error: "Forbidden" }) + } const table = resourceConfig[resource].table let whereCond: any = and(eq(table.id, id), eq(table.tenant, tenantId)) whereCond = applyResourceWhereFilters(resource, table, whereCond) + whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId) const projRows = await server.db .select() @@ -624,6 +703,10 @@ export default async function resourceRoutes(server: FastifyInstance) { try { if (!req.user?.tenant_id) return reply.code(400).send({ error: "No tenant selected" }); const { resource } = req.params as { resource: string }; + const portalCustomerId = await getPortalCustomerId(server, req) + if (portalCustomerId) { + return reply.code(403).send({ error: "Forbidden" }) + } if (resource === "accounts") { return reply.code(403).send({ error: "Accounts are read-only" }) } @@ -703,8 +786,12 @@ export default async function resourceRoutes(server: FastifyInstance) { const body = req.body as Record const tenantId = req.user?.tenant_id const userId = req.user?.user_id + const portalCustomerId = await getPortalCustomerId(server, req) if (!tenantId || !userId) return reply.code(401).send({ error: "Unauthorized" }) + if (portalCustomerId && resource !== "customers") { + return reply.code(403).send({ error: "Forbidden" }) + } const table = resourceConfig[resource].table const normalizeDate = (val: any) => { const d = new Date(val); return isNaN(d.getTime()) ? null : d; } @@ -712,13 +799,25 @@ export default async function resourceRoutes(server: FastifyInstance) { const [oldRecord] = await server.db .select() .from(table) - .where(and(eq(table.id, id), eq(table.tenant, tenantId))) + .where(applyPortalScope(resource, table, and(eq(table.id, id), eq(table.tenant, tenantId)), portalCustomerId)) .limit(1) + if (!oldRecord) { + return reply.code(404).send({ error: "Resource not found" }) + } + let data: Record = { ...body, updated_at: new Date().toISOString(), updated_by: userId } //@ts-ignore delete data.updatedBy; delete data.updatedAt; + if (portalCustomerId) { + data = { + ...sanitizePortalCustomerUpdate(data), + updated_at: data.updated_at, + updated_by: data.updated_by, + } + } + if (resource === "members") { data = normalizeMemberPayload(data) const validationError = validateMemberPayload(data) diff --git a/backend/src/utils/resource.config.ts b/backend/src/utils/resource.config.ts index 76aba95..ea7debb 100644 --- a/backend/src/utils/resource.config.ts +++ b/backend/src/utils/resource.config.ts @@ -192,7 +192,7 @@ export const resourceConfig = { table: createddocuments, mtoLoad: ["customer", "project", "contact", "contract", "plant","letterhead","createddocument"], mtmLoad: ["statementallocations","files","createddocuments"], - mtmListLoad: ["statementallocations"], + mtmListLoad: ["statementallocations", "files"], }, texttemplates: { table: texttemplates diff --git a/frontend/components/EntityShow.vue b/frontend/components/EntityShow.vue index bfae451..9cca494 100644 --- a/frontend/components/EntityShow.vue +++ b/frontend/components/EntityShow.vue @@ -50,10 +50,12 @@ const route = useRoute() const dataStore = useDataStore() const modal = useModal() const auth = useAuthStore() +const toast = useToast() const dataType = dataStore.dataTypes[type] const openTab = ref(String(route.query.tabIndex || 0)) +const portalInviteLoading = ref(false) @@ -152,6 +154,31 @@ const openCustomerInventoryLabelPrint = () => { }) } +const invitePortalUser = async () => { + if (type !== "customers" || !props.item?.id) return + + portalInviteLoading.value = true + + try { + const response = await useAdmin().invitePortalUser(Number(props.item.id)) + toast.add({ + title: "Portal-Einladung versendet", + description: `E-Mail: ${response.email}${response.initialPassword ? ` | Initialpasswort: ${response.initialPassword}` : ""}`, + timeout: 9000 + }) + emit("updateNeeded") + } catch (err) { + toast.add({ + title: "Portal-Einladung fehlgeschlagen", + description: err?.data?.error || "Die Einladung konnte nicht erstellt werden.", + color: "error", + timeout: 7000 + }) + } finally { + portalInviteLoading.value = false + } +} +