Einfügen eines centralen layouts getestet mit der serial-number.ejs und layout.ejs

This commit is contained in:
Cay 2026-01-22 09:01:28 +00:00
parent 642800b19a
commit 321018cee4
10 changed files with 549 additions and 9 deletions

239
app.js
View File

@ -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)
================================ */

View File

@ -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,
};

View File

@ -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 };

6
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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;
}

18
views/layout.ejs Normal file
View File

@ -0,0 +1,18 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>
<%= typeof title !== "undefined" ? title : "Privatarzt Software" %>
</title>
<!-- ✅ Global CSS -->
<link rel="stylesheet" href="/css/style.css" />
</head>
<body>
<%- body %>
</body>
</html>

View File

@ -71,6 +71,19 @@
<% } %>
</a>
<!-- ✅ Seriennummer (NEU) -->
<a
href="<%= hrefIfAllowed(isAdmin, '/serial-number') %>"
class="nav-item <%= active === 'serialnumber' ? 'active' : '' %> <%= lockClass(isAdmin) %>"
<%- lockClick(isAdmin) %>
title="<%= isAdmin ? '' : 'Nur Admin' %>"
>
<i class="bi bi-key"></i> Seriennummer
<% if (!isAdmin) { %>
<span style="margin-left:auto;"><i class="bi bi-lock-fill"></i></span>
<% } %>
</a>
<div class="spacer"></div>
<!-- ✅ Zurück zum Dashboard -->

View File

@ -0,0 +1,52 @@
<%
const titleText = typeof title !== "undefined" ? title : "";
const subtitleText = typeof subtitle !== "undefined" ? subtitle : "";
const showUser = typeof showUserName !== "undefined" ? showUserName : true;
%>
<div class="page-header">
<!-- LINKS -->
<div class="page-header-left"></div>
<!-- ✅ CENTER TEXT -->
<div class="page-header-center">
<% if (showUser && user?.username) { %>
<div class="page-header-username">
Willkommen, <%= user.username %>
</div>
<% } %>
<% if (titleText) { %>
<div class="page-header-title">
<%= titleText %>
<% if (subtitleText) { %>
<span class="page-header-subtitle"> - <%= subtitleText %></span>
<% } %>
</div>
<% } %>
</div>
<!-- ✅ RIGHT -->
<div class="page-header-right">
<a href="/dashboard" class="btn btn-outline-light btn-sm">
⬅️ Dashboard
</a>
<span id="datetime" class="page-header-datetime"></span>
</div>
</div>
<script>
(function () {
function updateDateTime() {
const el = document.getElementById("datetime");
if (!el) return;
el.textContent = new Date().toLocaleString("de-DE");
}
updateDateTime();
setInterval(updateDateTime, 1000);
})();
</script>

51
views/serial_number.ejs Normal file
View File

@ -0,0 +1,51 @@
<div class="layout">
<!-- MAIN CONTENT -->
<div class="main">
<!-- ✅ HEADER -->
<%- include("partials/page-header", {
user,
title: "Seriennummer",
subtitle: "Lizenz / Testphase",
showUserName: true
}) %>
<div class="content">
<h2>🔑 Seriennummer</h2>
<% if (error) { %>
<div class="alert alert-danger"><%= error %></div>
<% } %>
<% if (success) { %>
<div class="alert alert-success"><%= success %></div>
<% } %>
<% if (trialInfo) { %>
<div class="alert alert-warning"><%= trialInfo %></div>
<% } %>
<form method="POST" action="/serial-number" style="max-width:500px;">
<div class="form-group">
<label>Seriennummer (AAAAA-AAAAA-AAAAA-AAAAA)</label>
<input
type="text"
name="serial_number"
value="<%= currentSerial || '' %>"
class="form-control"
placeholder="ABCDE-12345-ABCDE-12345"
maxlength="23"
required
/>
</div>
<button class="btn btn-primary" style="margin-top:15px;">
Speichern
</button>
</form>
</div>
</div>
</div>