Initial scaffold: React/Vite/TS PWA + Express/Prisma/Postgres backend
Build and Deploy / version (push) Successful in 1s
Build and Deploy / build (push) Failing after 1s
Build and Deploy / deploy (push) Skipped

Materialschein & Kabelrechner für BergVOLT Elektrotechnik, portiert von der
vorherigen Single-File-HTML-Version. Microsoft-Entra-ID-Login (MSAL), Projekt-
Dashboard statt einer Datei pro Kunde, Retour-Buchen-Zusammenführung, 111
Kabeltypen aus den offiziellen Meinhart-Datenblättern. gitops/ nach
PROX/deploy-standard: git-backed Portainer-Stack, versionierte Docker-Images,
CI-Commit-back auf gitops/stack.yml.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fidelis Huber
2026-08-21 17:24:38 +02:00
co-authored by Claude Sonnet 5
commit 13cef3c96d
39 changed files with 1798 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
import type { NextFunction, Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
const CLIENT_ID = process.env.MSAL_CLIENT_ID ?? '';
const TENANT_ID = process.env.MSAL_TENANT_ID ?? '';
const jwks = jwksClient({
jwksUri: `https://login.microsoftonline.com/${TENANT_ID}/discovery/v2.0/keys`,
cache: true,
cacheMaxAge: 12 * 60 * 60 * 1000
});
function getKey(header: jwt.JwtHeader, callback: (err: Error | null, key?: string) => void) {
if (!header.kid) { callback(new Error('Kein kid im Token-Header')); return; }
jwks.getSigningKey(header.kid, (err, key) => {
if (err || !key) { callback(err ?? new Error('Kein Signing-Key gefunden')); return; }
callback(null, key.getPublicKey());
});
}
export interface AuthedRequest extends Request {
user?: { email: string; name: string; oid: string };
}
// Verifiziert das MSAL-Access-/ID-Token (Entra ID) im Authorization-Header.
// Prüft Signatur, Tenant (tid) und dass das Token für unsere App ausgestellt wurde (aud).
export function requireAuth(req: AuthedRequest, res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Kein Token übermittelt.' });
return;
}
const token = header.slice('Bearer '.length);
jwt.verify(token, getKey, { algorithms: ['RS256'] }, (err, decoded) => {
if (err || !decoded || typeof decoded === 'string') {
res.status(401).json({ error: 'Token ungültig: ' + (err?.message ?? 'unbekannt') });
return;
}
const claims = decoded as jwt.JwtPayload & { tid?: string; aud?: string; preferred_username?: string; upn?: string; name?: string; oid?: string };
if (claims.tid !== TENANT_ID) {
res.status(401).json({ error: 'Falscher Tenant.' });
return;
}
if (claims.aud !== CLIENT_ID) {
res.status(401).json({ error: 'Token nicht für diese App ausgestellt.' });
return;
}
const email = claims.preferred_username ?? claims.upn ?? '';
req.user = { email, name: claims.name ?? email, oid: claims.oid ?? '' };
next();
});
}
+32
View File
@@ -0,0 +1,32 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import express from 'express';
import cors from 'cors';
import { requireAuth } from './auth.js';
import { projekteRouter } from './routes/projekte.js';
import { materialRouter } from './routes/material.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT ?? 80);
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const app = express();
app.use(cors());
app.use(express.json());
app.get('/api/health', (_req, res) => res.json({ ok: true }));
app.use('/api/projekte', requireAuth, projekteRouter);
app.use('/api/material', requireAuth, materialRouter);
// Statisch gebautes React/Vite-Frontend ausliefern.
app.use(express.static(PUBLIC_DIR));
// SPA-Fallback für client-seitiges Routing (react-router) alles außer /api/*.
app.get(/^(?!\/api\/).*/, (_req, res) => {
res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
});
app.listen(PORT, () => {
console.log(`Materialschein-Server läuft auf Port ${PORT}`);
});
+3
View File
@@ -0,0 +1,3 @@
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();
+60
View File
@@ -0,0 +1,60 @@
import { Router } from 'express';
import { prisma } from '../prisma.js';
import type { AuthedRequest } from '../auth.js';
export const materialRouter = Router();
// GET /api/material?projektId=xxx
materialRouter.get('/', async (req, res) => {
const projektId = String(req.query.projektId ?? '');
if (!projektId) { res.status(400).json({ error: 'projektId erforderlich.' }); return; }
const items = await prisma.material.findMany({
where: { projektId },
orderBy: { createdAt: 'asc' }
});
res.json(items);
});
materialRouter.post('/', async (req: AuthedRequest, res) => {
const { projektId, bezeichnung, artNr, menge, einheit, notiz } = req.body ?? {};
if (!projektId || !bezeichnung || menge === undefined || !einheit) {
res.status(400).json({ error: 'projektId, bezeichnung, menge und einheit sind erforderlich.' });
return;
}
const item = await prisma.material.create({
data: {
projektId,
bezeichnung,
artNr: artNr || null,
menge,
einheit,
notiz: notiz || null,
erstelltVon: req.user?.email ?? null
}
});
res.status(201).json(item);
});
materialRouter.patch('/:id', async (req, res) => {
const { id } = req.params;
const { menge, notiz } = req.body ?? {};
const data: Record<string, unknown> = {};
if (menge !== undefined) data.menge = menge;
if (notiz !== undefined) data.notiz = notiz || null;
try {
const item = await prisma.material.update({ where: { id }, data });
res.json(item);
} catch {
res.status(404).json({ error: 'Position nicht gefunden.' });
}
});
materialRouter.delete('/:id', async (req, res) => {
const { id } = req.params;
try {
await prisma.material.delete({ where: { id } });
res.status(204).end();
} catch {
res.status(404).json({ error: 'Position nicht gefunden.' });
}
});
+46
View File
@@ -0,0 +1,46 @@
import { Router } from 'express';
import { prisma } from '../prisma.js';
import type { AuthedRequest } from '../auth.js';
export const projekteRouter = Router();
projekteRouter.get('/', async (_req, res) => {
const projekte = await prisma.projekt.findMany({ orderBy: { createdAt: 'desc' } });
res.json(projekte);
});
projekteRouter.post('/', async (req: AuthedRequest, res) => {
const { titel, auftraggeber, datum, monteur, notiz } = req.body ?? {};
if (!titel || typeof titel !== 'string' || !titel.trim()) {
res.status(400).json({ error: 'Titel (Projekt/Baustelle) ist erforderlich.' });
return;
}
const projekt = await prisma.projekt.create({
data: {
titel: titel.trim(),
auftraggeber: auftraggeber || null,
datum: datum ? new Date(datum) : null,
monteur: monteur || null,
notiz: notiz || null
}
});
res.status(201).json(projekt);
});
projekteRouter.patch('/:id', async (req, res) => {
const { id } = req.params;
const { titel, auftraggeber, datum, monteur, notiz, status } = req.body ?? {};
const data: Record<string, unknown> = {};
if (titel !== undefined) data.titel = titel;
if (auftraggeber !== undefined) data.auftraggeber = auftraggeber || null;
if (datum !== undefined) data.datum = datum ? new Date(datum) : null;
if (monteur !== undefined) data.monteur = monteur || null;
if (notiz !== undefined) data.notiz = notiz || null;
if (status !== undefined) data.status = status;
try {
const projekt = await prisma.projekt.update({ where: { id }, data });
res.json(projekt);
} catch {
res.status(404).json({ error: 'Projekt nicht gefunden.' });
}
});