Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10eacdeaf5 | |||
| d4030400b4 |
+4
-3
@@ -640,7 +640,7 @@ Kurz-Tasks (je gleiche Struktur: implementieren im passenden builtin-Paket + Wor
|
||||
## Phase H — Modi & Branchenneutralität
|
||||
- [x] H1 simple/pro Mode (uiStore.mode, Ribbon-Filter tags basic, CommandLine hide, Settings persist) *(2026-08-29: uiModeService statt uiStore — zustand war nie installiert (Q2 nie umgesetzt), Muster folgt documentService/featureFlags-Stil; localStorage webcad.uiMode persistiert, Default pro; RibbonBar filtert simple auf tags basic, CommandLine pro-only, Topbar-Toggle 😊/🛠; Log H1)*
|
||||
- [x] H2 Projekt-Templates (Halle/OpenAir/Saal/Messe/Buero/Garten): JSON Presets (grid unit, layers, enabled plugins, library folders seed) *(2026-08-29: projectTemplates-Service mit 6 Presets je gridSize/unit/4 Layer/enabledPlugins/libraryFolderSeed + applyProjectTemplate über CADDocument.replaceLayers; Log H2)*
|
||||
- [ ] H3 Rollen: library-admin (global write) vs user (own folders) — an bestehendes auth anbinden, Backend guard
|
||||
- [x] H3 Rollen: library-admin (global write) vs user (own folders) — an bestehendes auth anbinden, Backend guard *(2026-08-29: requireLibraryAdmin an bestehende admin-Rolle gebunden; alle 8 schreibenden Global-Library-Routen guarded (POST/PATCH/DELETE folders+blocks, import, import-zip), Lesen/Export für authentifizierte offen; Log H3)*
|
||||
- [ ] H4 i18n Grundlage: strings.ts zentrale Maps de/en, UI-Texte ersetzen (mechanisch, Teilautomatisierbar)
|
||||
- [ ] H5 Flächenberechnung Polygon (m²) Info im PropertiesPanel + measure-area Tool
|
||||
|
||||
@@ -667,6 +667,7 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
|
||||
|
||||
| Datum | Task | Zusammenfassung | Tests | Commit | Notizen |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-08-29 | H3 | Rollen library-admin vs. user: requireLibraryAdmin NEU in authMiddleware (an bestehende admin-Rolle gebunden — bewusst KEINE neue DB-Rolle, bestehendes users.role CHECK admin/planer/betrachter/gast bleibt unverändert); alle 8 schreibenden Global-Library-Routen auf requireLibraryAdmin umgestellt (POST/PATCH/DELETE /api/global-folders, POST/PATCH/DELETE /api/global-blocks, POST import, POST import-zip); Lesen (GET folders/blocks/export/export-zip) bleibt für ALLE authentifizierten Nutzer offen (planer kann Konsumieren aber nicht die globale Bibliothek verändern); zeichnungsscoped blocks-Routen unangetastet (per-drawing ownership dort); Fehlermeldung 403 „Library admin access required"; Test-Zwischenfall selbst erkannt: Header-Konstanten im describe-Body capturen undefined (describe-Body läuft VOR beforeAll) — nach Token-Debug (Länge undefined) Header-Objekte in beforeAll-Zuweisung umgestellt, dann sauberes ROT (8 Schreib-Tests) → GRÜN | Backend: 287/287 Tests (+14 libraryAdminGuard.test.ts: 3 Lese-für-planer, 8×403-Schreiben-für-planer, 2 Admin-201, 1 Unauth-401), BE tsc 0 | d403040 | H3 COMPLETE; nächster Task H4 (i18n-Grundlage) |
|
||||
| 2026-08-29 | H2 | Projekt-Templates: projectTemplates.ts NEU — ProjectTemplate-Typ (id/name/description/gridSize/unit/layers/enabledPlugins/libraryFolderSeed) + 6 Branchen-Presets als TS-Konstanten (JSON-äquivalent, ohne Datei-IO direkt tree-shakebar): halle (Veranstaltungshalle, 100cm-Raster, Wände/Bestuhlung/Bühne/Technik, event-tools), openair (Zonen/Bestuhlung/Absperrung/Wege, event-tools), saal (Bestuhlung/Bühne/Catering/Deko, 50cm), messe (Standlayout/Theken/Medien/Strom, 10mm), büro (Wände/Möbel/Netzwerk/Beschriftung, 10mm), garten (Bepflanzung/Wege/Wasser/Beleuchtung, 100cm) — je 4 Layer mit eindeutigen tpl-*-IDs, enabledPlugins inkl. core-*/builtin-library; applyProjectTemplate überträgt Layer-Set ERSETZEND (Template = Startzustand) via neuem CADDocument.replaceLayers (transaktionsgebündelt: alle deletes+sets in EINER Transaktion = 1 Undo-Schritt); gridSize/unit/pluginSeed sind UI-Einstellungen, die der Aufrufer aus dem Template liest (Dokumentation im Service) | Frontend: 602/602 Tests (+6 projectTemplates.test.ts), tsc 0, Build EXIT 0 | 131a922 | H2 COMPLETE; nächster Task H3 (Rollen library-admin vs. user, Backend-Guard) |
|
||||
| 2026-08-29 | H1 | simple/pro Mode: (1) uiModeService NEU — getUIMode/setUIMode/toggleUIMode/subscribeUIMode + useUIMode-React-Hook; localStorage-Persistenz (webcad.uiMode, Default pro, ungültige Werte → pro-Fallback, try/catch für nicht verfügbares localStorage); BEWUSST kein zustand-Store (Q2-Entscheidung wurde nie umgesetzt, kein stores/-Verzeichnis vorhanden) — Service folgt dem etablierten documentService/featureFlags-Muster des Projekts; (2) RibbonBar: V2-Tool-Sektion filtert im simple-Modus auf manifest.tags includes basic (useUIMode-Hook, Live-Update bei Toggle); (3) CommandLine: useUIMode → simple rendert null (pro-only); (4) Topbar: Toggle-Button 😊(→simple)/🛠(→pro) in topbar-right; Test-Zwischenfälle: CommandLine-Root-Klasse heißt .cmdline nicht .command-line (Selektor fixiert), Topbar braucht Pflicht-Props (Test-Render angepasst) | Frontend: 596/596 Tests (+9 uiMode.test.tsx), tsc 0, Build EXIT 0 | 8774fea | H1 COMPLETE; nächster Task H2 (Projekt-Templates) |
|
||||
| 2026-08-29 | G5 | Browser-Print stylesheet: (1) @media print Block in styles.css — @page size A4 landscape margin 10mm; .screen-only display none !important (UI-Chrome); .print-area width/height 100% overflow visible (Druckbereich füllt Seite); .print-layout * -webkit-print-color-adjust + print-color-adjust exact (Farben/Linien wie angezeigt drucken); body weiß + overflow hidden; (2) printService NEU — preparePrintLayout setzt body.print-layout-Klasse, cleanupPrintLayout entfernt (kein DOM-Restzustand), printLayout: prepare → 2× requestAnimationFrame (Styles greifen vor Print-Dialog) → window.print → finally-cleanup (auch bei Abbruch); (3) LayoutBar-Druck-Button 🖨 ruft printLayout (screen-only-Klasse); Tests: Service-Interaktion (Klasse setzen/entfernen, print-Spy + Cleanup) + CSS-Datei-Assertions (@page-Regex, screen-only/print-area-Regeln, print-color-adjust) | Frontend: 587/587 Tests (+7 printStylesheet.test.ts), tsc 0, Build EXIT 0 | d9a0dff | **G5 COMPLETE — PHASE G VOLLSTÄNDIG** (Layouts & Plot: G1 Datenmodell, G2 LayoutBar, G3 Titelblock, G4 PDF, G5 Print); nächster Schritt: Phase H ab H1 (simple/pro Mode) |
|
||||
@@ -739,5 +740,5 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
|
||||
- `frontend/src/utils/__tests__/format.test.ts` liegt in src statt tests/ — vereinheitlichen (kleiner Task in I2)
|
||||
|
||||
---
|
||||
*Nächster offener Schritt: **H3** (Rollen: library-admin global write vs. user own folders — an bestehendes auth anbinden, Backend guard). Danach: H4 i18n, H5 Flächen. Restlich: D1–D4/D9/D10-path/D11/D12, Phase I (Release).*
|
||||
*Status 2026-08-29: Phasen B+A+C+D+E+F+G komplett, H1+H2 fertig (simple/pro Mode + Projekt-Templates), BUG-1-Kollisionsklasse geschlossen. FE 602/602 Tests, BE 272/272, tsc/Build grün. Alle Commits auf Forgejo main.*
|
||||
*Nächster offener Schritt: **H4** (i18n-Grundlage: strings.ts zentrale Maps de/en, UI-Texte ersetzen — mechanisch, teilautomatisierbar). Danach: H5 Flächenberechnung. Restlich: D1–D4/D9/D10-path/D11/D12, Phase I (Release).*
|
||||
*Status 2026-08-29: Phasen B+A+C+D+E+F+G komplett, H1+H2+H3 fertig (simple/pro Mode, Projekt-Templates, Rollen-Guard), BUG-1-Kollisionsklasse geschlossen. FE 602/602 Tests, BE 287/287, tsc/Build grün. Alle Commits auf Forgejo main.*
|
||||
|
||||
@@ -44,3 +44,20 @@ export function requireAdmin(request: FastifyRequest, reply: FastifyReply, authS
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require library-admin role (Task H3: global library write guard).
|
||||
* Library-admin is bound to the existing admin role: only admins may
|
||||
* write (create/update/delete/import) the GLOBAL block library, while
|
||||
* every authenticated user may read/export it. Own drawing-scoped
|
||||
* blocks are unaffected (per-user ownership there).
|
||||
*/
|
||||
export function requireLibraryAdmin(request: FastifyRequest, reply: FastifyReply, authService: AuthService): DBUser | null {
|
||||
const user = requireAuth(request, reply, authService);
|
||||
if (!user) return null;
|
||||
if (user.role !== 'admin') {
|
||||
reply.code(403).send({ error: 'Library admin access required' });
|
||||
return null;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
|
||||
import { requireAuth } from '../auth/authMiddleware.js';
|
||||
import { requireAuth, requireLibraryAdmin } from '../auth/authMiddleware.js';
|
||||
import type { AuthService } from '../auth/AuthService.js';
|
||||
import { validateName, validateIdParam } from '../utils/validation.js';
|
||||
import JSZip from 'jszip';
|
||||
@@ -36,7 +36,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
|
||||
|
||||
// Create a new folder
|
||||
fastify.post('/api/global-folders', async (request, reply) => {
|
||||
if (!requireAuth(request, reply, authService)) return;
|
||||
if (!requireLibraryAdmin(request, reply, authService)) return;
|
||||
const body = request.body as { name?: string; parent_id?: string | null };
|
||||
const nameErr = validateName(body.name, 'name');
|
||||
if (nameErr) return reply.code(400).send({ error: nameErr });
|
||||
@@ -49,7 +49,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
|
||||
|
||||
// Rename / move a folder
|
||||
fastify.patch('/api/global-folders/:id', async (request, reply) => {
|
||||
if (!requireAuth(request, reply, authService)) return;
|
||||
if (!requireLibraryAdmin(request, reply, authService)) return;
|
||||
const { id } = request.params as { id: string };
|
||||
const idErr = validateIdParam(id, 'id');
|
||||
if (idErr) return reply.code(400).send({ error: idErr });
|
||||
@@ -71,7 +71,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
|
||||
|
||||
// Delete a folder (cascades to children, blocks get folder_id = NULL)
|
||||
fastify.delete('/api/global-folders/:id', async (request, reply) => {
|
||||
if (!requireAuth(request, reply, authService)) return;
|
||||
if (!requireLibraryAdmin(request, reply, authService)) return;
|
||||
const { id } = request.params as { id: string };
|
||||
const idErr = validateIdParam(id, 'id');
|
||||
if (idErr) return reply.code(400).send({ error: idErr });
|
||||
@@ -107,7 +107,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
|
||||
|
||||
// Create a new global block
|
||||
fastify.post('/api/global-blocks', async (request, reply) => {
|
||||
if (!requireAuth(request, reply, authService)) return;
|
||||
if (!requireLibraryAdmin(request, reply, authService)) return;
|
||||
const body = request.body as { name?: string; folder_id?: string | null; block_data?: string; svg_data?: string };
|
||||
const nameErr = validateName(body.name, 'name');
|
||||
if (nameErr) return reply.code(400).send({ error: nameErr });
|
||||
@@ -131,7 +131,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
|
||||
|
||||
// Update a global block (rename, move, update data)
|
||||
fastify.patch('/api/global-blocks/:id', async (request, reply) => {
|
||||
if (!requireAuth(request, reply, authService)) return;
|
||||
if (!requireLibraryAdmin(request, reply, authService)) return;
|
||||
const { id } = request.params as { id: string };
|
||||
const idErr = validateIdParam(id, 'id');
|
||||
if (idErr) return reply.code(400).send({ error: idErr });
|
||||
@@ -160,7 +160,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
|
||||
|
||||
// Delete a global block
|
||||
fastify.delete('/api/global-blocks/:id', async (request, reply) => {
|
||||
if (!requireAuth(request, reply, authService)) return;
|
||||
if (!requireLibraryAdmin(request, reply, authService)) return;
|
||||
const { id } = request.params as { id: string };
|
||||
const idErr = validateIdParam(id, 'id');
|
||||
if (idErr) return reply.code(400).send({ error: idErr });
|
||||
@@ -192,7 +192,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
|
||||
|
||||
// Import: .wcadlib-Paket einspielen (IDs neu, Ordner-Hierarchie über Mapping erhalten)
|
||||
fastify.post('/api/global-blocks/import', async (request, reply) => {
|
||||
if (!requireAuth(request, reply, authService)) return;
|
||||
if (!requireLibraryAdmin(request, reply, authService)) return;
|
||||
const pkg = request.body as {
|
||||
format?: string;
|
||||
folders?: Array<{ id: string; name: string; parent_id: string | null }>;
|
||||
@@ -280,7 +280,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
|
||||
|
||||
// ZIP-Import: .wcadlib-ZIP einspielen (Ordner-Mapping, Blöcke, Thumbnails)
|
||||
fastify.post('/api/global-blocks/import-zip', async (request, reply) => {
|
||||
if (!requireAuth(request, reply, authService)) return;
|
||||
if (!requireLibraryAdmin(request, reply, authService)) return;
|
||||
const body = request.body;
|
||||
if (!Buffer.isBuffer(body) || body.length === 0) {
|
||||
return reply.code(400).send({ error: 'ZIP body fehlt oder leer' });
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Task H3 – Rollen: library-admin (global write) vs. user (read-only).
|
||||
*
|
||||
* requireLibraryAdmin schützt alle schreibenden Global-Library-Routen:
|
||||
* POST/PATCH/DELETE auf folders+blocks, import, import-zip. Lesen
|
||||
* (GET, export) bleibt für alle authentifizierten Nutzer offen.
|
||||
* library-admin ist an die bestehende admin-Rolle angebunden.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
|
||||
import { createServer } from '../src/server.js';
|
||||
|
||||
describe('Task H3: library-admin Guard', () => {
|
||||
let app: FastifyInstance;
|
||||
let db: SqliteAdapter;
|
||||
let adminToken: string;
|
||||
let planerToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = new SqliteAdapter(':memory:');
|
||||
await db.init();
|
||||
app = await createServer({ db, port: 0 });
|
||||
await app.ready();
|
||||
|
||||
const adminReg = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/register',
|
||||
payload: { email: 'h3-admin@example.com', password: 'Password123!', name: 'H3 Admin', role: 'admin' },
|
||||
});
|
||||
if (adminReg.statusCode !== 201) console.error('ADMIN REG FAILED:', adminReg.statusCode, adminReg.body.slice(0, 200));
|
||||
adminToken = JSON.parse(adminReg.body).session?.token ?? '';
|
||||
|
||||
const planerReg = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/register',
|
||||
payload: { email: 'h3-planer@example.com', password: 'Password123!', name: 'H3 Planer', role: 'planer' },
|
||||
});
|
||||
if (planerReg.statusCode !== 201) console.error('PLANER REG FAILED:', planerReg.statusCode, planerReg.body.slice(0, 200));
|
||||
planerToken = JSON.parse(planerReg.body).session?.token ?? '';
|
||||
admin = { authorization: `Bearer ${adminToken}` };
|
||||
planer = { authorization: `Bearer ${planerToken}` };
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
// WICHTIG: Header-Objekte werden in beforeAll gebaut — der describe-Body
|
||||
// läuft VOR den Hooks, Template-Literale würden undefined capturen.
|
||||
let planer: Record<string, string>;
|
||||
let admin: Record<string, string>;
|
||||
|
||||
// ── Lesen bleibt offen (beide Rollen) ──
|
||||
|
||||
it('planer kann globale Ordner LESEN (200)', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/global-folders', headers: planer });
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('planer kann globale Blöcke LESEN (200)', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/global-blocks', headers: planer });
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('planer kann Library EXPORTIEREN (GET export, 200)', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/global-blocks/export', headers: planer });
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
// ── Schreiben nur für library-admin (admin-Rolle) ──
|
||||
|
||||
it('planer POST /api/global-folders → 403', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST', url: '/api/global-folders', headers: planer,
|
||||
payload: { name: 'Verboten', parent_id: null },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(JSON.parse(res.body).error).toContain('Library');
|
||||
});
|
||||
|
||||
it('planer POST /api/global-blocks → 403', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST', url: '/api/global-blocks', headers: planer,
|
||||
payload: { name: 'Verboten', block_data: '[]' },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('planer PATCH /api/global-folders/:id → 403', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'PATCH', url: '/api/global-folders/irgendeine-id', headers: planer,
|
||||
payload: { name: 'Hack' },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('planer PATCH /api/global-blocks/:id → 403', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'PATCH', url: '/api/global-blocks/irgendeine-id', headers: planer,
|
||||
payload: { name: 'Hack' },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('planer DELETE /api/global-folders/:id → 403', async () => {
|
||||
const res = await app.inject({ method: 'DELETE', url: '/api/global-folders/irgendeine-id', headers: planer });
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('planer DELETE /api/global-blocks/:id → 403', async () => {
|
||||
const res = await app.inject({ method: 'DELETE', url: '/api/global-blocks/irgendeine-id', headers: planer });
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('planer POST /api/global-blocks/import → 403', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST', url: '/api/global-blocks/import', headers: planer,
|
||||
payload: { format: 'wcadlib', blocks: [] },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('planer POST /api/global-blocks/import-zip → 403', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST', url: '/api/global-blocks/import-zip', headers: { ...planer, 'content-type': 'application/zip' },
|
||||
payload: Buffer.from('PK'),
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
// ── Admin (library-admin) darf schreiben ──
|
||||
|
||||
it('admin POST /api/global-folders → 201 (write erlaubt)', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST', url: '/api/global-folders', headers: admin,
|
||||
payload: { name: 'Admin Set', parent_id: null },
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it('admin POST /api/global-blocks → 201 (write erlaubt)', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST', url: '/api/global-blocks', headers: admin,
|
||||
payload: { name: 'Admin Block', block_data: '[]', folder_id: null },
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
});
|
||||
|
||||
// ── Unauthentifiziert bleibt 401 ──
|
||||
|
||||
it('ohne Token POST /api/global-folders → 401 (nicht 403)', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST', url: '/api/global-folders',
|
||||
payload: { name: 'Anon', parent_id: null },
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user