From 321018cee4736ebb3e0a8e926b89ed3cbbd27103 Mon Sep 17 00:00:00 2001 From: Cay Date: Thu, 22 Jan 2026 09:01:28 +0000 Subject: [PATCH] =?UTF-8?q?Einf=C3=BCgen=20eines=20centralen=20layouts=20g?= =?UTF-8?q?etestet=20mit=20der=20serial-number.ejs=20und=20layout.ejs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 239 ++++++++++++++++++++++++++++++- controllers/auth.controller.js | 27 ++-- middleware/license.middleware.js | 52 +++++++ package-lock.json | 6 + package.json | 1 + public/css/style.css | 99 +++++++++++++ views/layout.ejs | 18 +++ views/partials/admin-sidebar.ejs | 13 ++ views/partials/page-header.ejs | 52 +++++++ views/serial_number.ejs | 51 +++++++ 10 files changed, 549 insertions(+), 9 deletions(-) create mode 100644 middleware/license.middleware.js create mode 100644 views/layout.ejs create mode 100644 views/partials/page-header.ejs create mode 100644 views/serial_number.ejs diff --git a/app.js b/app.js index c6f3b49..ad43168 100644 --- a/app.js +++ b/app.js @@ -6,6 +6,7 @@ const helmet = require("helmet"); const mysql = require("mysql2/promise"); const fs = require("fs"); const path = require("path"); +const expressLayouts = require("express-ejs-layouts"); // ✅ Verschlüsselte Config const { configExists, saveConfig } = require("./config-manager"); @@ -30,6 +31,39 @@ const authRoutes = require("./routes/auth.routes"); const app = express(); +/* =============================== + ✅ Seriennummer / Trial Konfiguration +================================ */ +const TRIAL_DAYS = 30; + +/* =============================== + ✅ Seriennummer Helper Funktionen +================================ */ +function normalizeSerial(input) { + return (input || "") + .toUpperCase() + .replace(/[^A-Z0-9-]/g, "") + .trim(); +} + +// Format: AAAAA-AAAAA-AAAAA-AAAAA +function isValidSerialFormat(serial) { + return /^[A-Z0-9]{5}(-[A-Z0-9]{5}){3}$/.test(serial); +} + +// Modulo-3 Check (Summe aller Zeichenwerte % 3 === 0) +function passesModulo3(serial) { + const raw = serial.replace(/-/g, ""); + let sum = 0; + + for (const ch of raw) { + if (/[0-9]/.test(ch)) sum += parseInt(ch, 10); + else sum += ch.charCodeAt(0) - 55; // A=10 + } + + return sum % 3 === 0; +} + /* =============================== SETUP HTML ================================ */ @@ -118,11 +152,76 @@ app.use(flashMiddleware); app.use(express.static("public")); app.use("/uploads", express.static("uploads")); app.set("view engine", "ejs"); +app.use(expressLayouts); +app.set("layout", "layout"); // verwendet views/layout.ejs + app.use((req, res, next) => { res.locals.user = req.session.user || null; next(); }); +/* =============================== + ✅ LICENSE/TRIAL GATE (NEU!) + - wenn keine Seriennummer: 30 Tage Trial + - danach nur noch /serial-number erreichbar +================================ */ +app.use(async (req, res, next) => { + try { + // Setup muss immer erreichbar bleiben + if (req.path.startsWith("/setup")) return next(); + + // Login muss erreichbar bleiben + if (req.path === "/" || req.path.startsWith("/login")) return next(); + + // Seriennummer Seite muss immer erreichbar bleiben + if (req.path.startsWith("/serial-number")) return next(); + + // Nicht eingeloggt -> auth regelt das + if (!req.session?.user) return next(); + + // company_settings laden + const [rows] = await db.promise().query( + `SELECT id, serial_number, trial_started_at + FROM company_settings + ORDER BY id ASC + LIMIT 1`, + ); + + const settings = rows?.[0]; + + // ✅ Lizenz vorhanden -> erlaubt + if (settings?.serial_number) return next(); + + // ✅ wenn Trial noch nicht gestartet -> starten + if (settings?.id && !settings?.trial_started_at) { + await db + .promise() + .query( + `UPDATE company_settings SET trial_started_at = NOW() WHERE id = ?`, + [settings.id], + ); + return next(); + } + + // Wenn settings fehlen -> durchlassen (damit Setup/Settings nicht kaputt gehen) + if (!settings?.trial_started_at) return next(); + + // ✅ Trial prüfen + const trialStart = new Date(settings.trial_started_at); + const now = new Date(); + + const diffDays = Math.floor((now - trialStart) / (1000 * 60 * 60 * 24)); + + if (diffDays < TRIAL_DAYS) return next(); + + // ❌ Trial abgelaufen -> alles blocken außer Seriennummer + return res.redirect("/serial-number"); + } catch (err) { + console.error("❌ LicenseGate Fehler:", err.message); + return next(); // im Zweifel nicht blockieren + } +}); + /* =============================== SETUP ROUTES ================================ */ @@ -181,7 +280,10 @@ app.use((req, res, next) => { next(); }); -//Sprachen Route +/* =============================== + Sprachen Route +================================ */ + // ✅ i18n Middleware (Sprache pro Benutzer über Session) app.use((req, res, next) => { const lang = req.session.lang || "de"; // Standard: Deutsch @@ -218,6 +320,141 @@ app.get("/lang/:lang", (req, res) => { }); }); +/* =============================== + ✅ Seriennummer Seite (NEU!) +================================ */ + +// ✅ GET /serial-number +app.get("/serial-number", async (req, res) => { + try { + if (!req.session?.user) return res.redirect("/"); + + const [rows] = await db.promise().query( + `SELECT serial_number, trial_started_at + FROM company_settings + ORDER BY id ASC + LIMIT 1`, + ); + + const settings = rows?.[0]; + + let trialInfo = null; + + if (!settings?.serial_number && settings?.trial_started_at) { + const trialStart = new Date(settings.trial_started_at); + const now = new Date(); + const diffDays = Math.floor((now - trialStart) / (1000 * 60 * 60 * 24)); + const rest = Math.max(0, TRIAL_DAYS - diffDays); + + trialInfo = `⚠️ Keine Seriennummer vorhanden. Testphase: noch ${rest} Tage.`; + } + + return res.render("serial_number", { + user: req.session.user, + active: "serialnumber", + currentSerial: settings?.serial_number || "", + error: null, + success: null, + trialInfo, + }); + } catch (err) { + console.error(err); + return res.status(500).send("Interner Serverfehler"); + } +}); + +// ✅ POST /serial-number +app.post("/serial-number", async (req, res) => { + try { + if (!req.session?.user) return res.redirect("/"); + + let serial = normalizeSerial(req.body.serial_number); + + if (!serial) { + return res.render("serial_number", { + user: req.session.user, + active: "serialnumber", + currentSerial: "", + error: "Bitte Seriennummer eingeben.", + success: null, + trialInfo: null, + }); + } + + if (!isValidSerialFormat(serial)) { + return res.render("serial_number", { + user: req.session.user, + active: "serialnumber", + currentSerial: serial, + error: "Ungültiges Format. Beispiel: ABC12-3DE45-FG678-HI901", + success: null, + trialInfo: null, + }); + } + + if (!passesModulo3(serial)) { + return res.render("serial_number", { + user: req.session.user, + active: "serialnumber", + currentSerial: serial, + error: "Seriennummer ungültig (Modulo-3 Prüfung fehlgeschlagen).", + success: null, + trialInfo: null, + }); + } + + // company_settings holen + const [rows] = await db + .promise() + .query( + `SELECT id, trial_started_at FROM company_settings ORDER BY id ASC LIMIT 1`, + ); + + if (!rows.length) { + // Wenn noch kein Datensatz existiert -> anlegen + await db.promise().query( + `INSERT INTO company_settings + (company_name, street, house_number, postal_code, city, country, default_currency, serial_number, trial_started_at) + VALUES ('', '', '', '', '', 'Deutschland', 'EUR', ?, NOW())`, + [serial], + ); + } else { + const settingsId = rows[0].id; + + await db.promise().query( + `UPDATE company_settings + SET serial_number = ? + WHERE id = ?`, + [serial, settingsId], + ); + } + + return res.render("serial_number", { + user: req.session.user, + active: "serialnumber", + currentSerial: serial, + error: null, + success: "✅ Seriennummer gespeichert!", + trialInfo: null, + }); + } catch (err) { + console.error(err); + + let msg = "Fehler beim Speichern."; + if (err.code === "ER_DUP_ENTRY") + msg = "Diese Seriennummer ist bereits vergeben."; + + return res.render("serial_number", { + user: req.session.user, + active: "serialnumber", + currentSerial: req.body.serial_number || "", + error: msg, + success: null, + trialInfo: null, + }); + } +}); + /* =============================== DEINE LOGIK (unverändert) ================================ */ diff --git a/controllers/auth.controller.js b/controllers/auth.controller.js index 055cb47..d613747 100644 --- a/controllers/auth.controller.js +++ b/controllers/auth.controller.js @@ -7,16 +7,27 @@ async function postLogin(req, res) { const { username, password } = req.body; try { - const user = await loginUser( - db, - username, - password, - LOCK_TIME_MINUTES - ); + const user = await loginUser(db, username, password, LOCK_TIME_MINUTES); + + /* req.session.user = user; + res.redirect("/dashboard"); */ req.session.user = user; - res.redirect("/dashboard"); + // ✅ Direkt nach Login check: + const [rows] = await db + .promise() + .query( + `SELECT serial_number, trial_started_at FROM company_settings ORDER BY id ASC LIMIT 1`, + ); + + const settings = rows?.[0]; + + if (!settings?.serial_number) { + return res.redirect("/serial-number"); + } + + res.redirect("/dashboard"); } catch (error) { res.render("login", { error }); } @@ -28,5 +39,5 @@ function getLogin(req, res) { module.exports = { getLogin, - postLogin + postLogin, }; diff --git a/middleware/license.middleware.js b/middleware/license.middleware.js new file mode 100644 index 0000000..509d5d1 --- /dev/null +++ b/middleware/license.middleware.js @@ -0,0 +1,52 @@ +const db = require("../db"); + +const TRIAL_DAYS = 30; + +async function licenseGate(req, res, next) { + // Login-Seiten immer erlauben + if (req.path === "/" || req.path.startsWith("/login")) return next(); + + // Seriennummer-Seite immer erlauben + if (req.path.startsWith("/serial-number")) return next(); + + // Wenn nicht eingeloggt -> normal weiter (auth middleware macht das) + if (!req.session?.user) return next(); + + const [rows] = await db + .promise() + .query( + `SELECT serial_number, trial_started_at FROM company_settings ORDER BY id ASC LIMIT 1`, + ); + + const settings = rows?.[0]; + + // Wenn Seriennummer vorhanden -> alles ok + if (settings?.serial_number) return next(); + + // Wenn keine Trial gestartet: jetzt starten + if (!settings?.trial_started_at) { + await db + .promise() + .query( + `UPDATE company_settings SET trial_started_at = NOW() WHERE id = ?`, + [settings?.id || 1], + ); + return next(); // Trial läuft ab jetzt + } + + // Trial prüfen + const trialStart = new Date(settings.trial_started_at); + const now = new Date(); + + const diffMs = now - trialStart; + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays < TRIAL_DAYS) { + return next(); // Trial ist noch gültig + } + + // ❌ Trial abgelaufen -> nur noch Seriennummer Seite + return res.redirect("/serial-number"); +} + +module.exports = { licenseGate }; diff --git a/package-lock.json b/package-lock.json index 82692a3..d990106 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "dotenv": "^17.2.3", "ejs": "^3.1.10", "express": "^4.19.2", + "express-ejs-layouts": "^2.5.1", "express-mysql-session": "^3.0.3", "express-session": "^1.18.2", "fs-extra": "^11.3.3", @@ -3045,6 +3046,11 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-ejs-layouts": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/express-ejs-layouts/-/express-ejs-layouts-2.5.1.tgz", + "integrity": "sha512-IXROv9n3xKga7FowT06n1Qn927JR8ZWDn5Dc9CJQoiiaaDqbhW5PDmWShzbpAa2wjWT1vJqaIM1S6vJwwX11gA==" + }, "node_modules/express-mysql-session": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/express-mysql-session/-/express-mysql-session-3.0.3.tgz", diff --git a/package.json b/package.json index e6c3afc..281d3b2 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dotenv": "^17.2.3", "ejs": "^3.1.10", "express": "^4.19.2", + "express-ejs-layouts": "^2.5.1", "express-mysql-session": "^3.0.3", "express-session": "^1.18.2", "fs-extra": "^11.3.3", diff --git a/public/css/style.css b/public/css/style.css index 5d3a129..e82654a 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -78,3 +78,102 @@ visibility: hidden; } } + +/* ========================================================= + ✅ PAGE HEADER (global) + - Höhe ca. 4cm + - Hintergrund schwarz + - Text in der Mitte + - Button + Datum/Uhrzeit rechts +========================================================= */ + +/* ✅ Der komplette Header-Container */ +.page-header { + height: 150px; /* ca. 4cm */ + background: #000; /* Schwarz */ + color: #fff; /* Weiße Schrift */ + + /* Wir nutzen Grid, damit Center wirklich immer mittig bleibt */ + display: grid; + + /* 3 Spalten: + 1) links = leer/optional + 2) mitte = Text (center) + 3) rechts = Dashboard + Uhrzeit + */ + grid-template-columns: 1fr 2fr 1fr; + + align-items: center; /* vertikal mittig */ + padding: 0 20px; /* links/rechts Abstand */ + box-sizing: border-box; +} + +/* ✅ Linke Header-Spalte (kann leer bleiben oder später Logo) */ +.page-header-left { + justify-self: start; /* ganz links */ +} + +/* ✅ Mittlere Header-Spalte (Text zentriert) */ +.page-header-center { + justify-self: center; /* wirklich zentriert in der Mitte */ + text-align: center; + + display: flex; + flex-direction: column; /* Username oben, Titel darunter */ + gap: 6px; /* Abstand zwischen den Zeilen */ +} + +/* ✅ Rechte Header-Spalte (Button + Uhrzeit rechts) */ +.page-header-right { + justify-self: end; /* ganz rechts */ + + display: flex; + flex-direction: column; /* Button oben, Uhrzeit unten */ + align-items: flex-end; /* alles rechts ausrichten */ + gap: 10px; /* Abstand Button / Uhrzeit */ +} + +/* ✅ Username-Zeile (z.B. Willkommen, admin) */ +.page-header-username { + font-size: 22px; + font-weight: 600; + margin: 0; +} + +/* ✅ Titel-Zeile (z.B. Seriennummer) */ +.page-header-title { + font-size: 18px; + opacity: 0.9; +} + +/* ✅ Subtitle Bereich (optional) */ +.page-header-subtitle { + opacity: 0.75; +} + +/* ✅ Uhrzeit (oben rechts unter dem Button) */ +.page-header-datetime { + font-size: 14px; + opacity: 0.85; +} + +/* ✅ Dashboard Button (weißer Rahmen) */ +.page-header .btn-outline-light { + border-color: #fff !important; + color: #fff !important; +} + +/* ✅ Dashboard Button: keine Unterstreichung + Rahmen + rund */ +.page-header a.btn { + text-decoration: none !important; /* keine Unterstreichung */ + border: 2px solid #fff !important; /* Rahmen */ + border-radius: 12px; /* abgerundete Ecken */ + padding: 6px 12px; /* schöner Innenabstand */ + display: inline-block; /* saubere Button-Form */ +} + +/* ✅ Dashboard Button (Hovereffekt) */ +.page-header a.btn:hover { + background: #fff !important; + color: #000 !important; +} diff --git a/views/layout.ejs b/views/layout.ejs new file mode 100644 index 0000000..bdc27c8 --- /dev/null +++ b/views/layout.ejs @@ -0,0 +1,18 @@ + + + + + + + + <%= typeof title !== "undefined" ? title : "Privatarzt Software" %> + + + + + + + + <%- body %> + + diff --git a/views/partials/admin-sidebar.ejs b/views/partials/admin-sidebar.ejs index e134499..7d2850a 100644 --- a/views/partials/admin-sidebar.ejs +++ b/views/partials/admin-sidebar.ejs @@ -71,6 +71,19 @@ <% } %> + + + title="<%= isAdmin ? '' : 'Nur Admin' %>" + > + Seriennummer + <% if (!isAdmin) { %> + + <% } %> + +
diff --git a/views/partials/page-header.ejs b/views/partials/page-header.ejs new file mode 100644 index 0000000..45dffe7 --- /dev/null +++ b/views/partials/page-header.ejs @@ -0,0 +1,52 @@ +<% + const titleText = typeof title !== "undefined" ? title : ""; + const subtitleText = typeof subtitle !== "undefined" ? subtitle : ""; + const showUser = typeof showUserName !== "undefined" ? showUserName : true; +%> + + + + diff --git a/views/serial_number.ejs b/views/serial_number.ejs new file mode 100644 index 0000000..fa44491 --- /dev/null +++ b/views/serial_number.ejs @@ -0,0 +1,51 @@ +
+ + +
+ + + <%- include("partials/page-header", { + user, + title: "Seriennummer", + subtitle: "Lizenz / Testphase", + showUserName: true + }) %> + +
+ +

🔑 Seriennummer

+ + <% if (error) { %> +
<%= error %>
+ <% } %> + + <% if (success) { %> +
<%= success %>
+ <% } %> + + <% if (trialInfo) { %> +
<%= trialInfo %>
+ <% } %> + +
+
+ + +
+ + +
+ +
+
+