feat(api,web): eigene Module — Tabelle, API, Seitenleiste, Rahmen-Seite

- Tabelle CustomModule mit Zeilenschutz (tenant_isolation_policy), Migration 20260929120000
- API /custom-modules: Lesen für jeden Angemeldeten, Schreiben nur Administrator, nur https ohne Zugangsdaten
- Seitenleiste zeigt eigene Module unter ihrer Kategorie, Rahmen-Seite mit Sandbox und „In neuem Tab öffnen“
- MODULE_CATEGORIES als gemeinsame Liste, Zugriffsklassifikation nachgemessen fortgeschrieben

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 07:25:33 +02:00
parent 643b1a2caa
commit b9d87be360
23 changed files with 1270 additions and 40 deletions
@@ -0,0 +1,66 @@
import { PartialType } from '@nestjs/mapped-types';
import { MODULE_CATEGORIES } from '@tessera/shared';
import { Transform } from 'class-transformer';
import {
IsIn,
IsNotEmpty,
IsString,
MaxLength,
Validate,
ValidatorConstraint,
type ValidatorConstraintInterface,
} from 'class-validator';
/**
* Adresse eines eigenen Moduls (T-9WC-03, T-9WC-06): gueltig nur, wenn der
* URL-Parser sie annimmt, das Schema `https:` ist, ein Rechnername da ist und
* weder Benutzername noch Kennwort in der Adresse stehen — sonst saehe jeder
* Benutzer die Zugangsdaten. `javascript:`, `data:`, `http:` und `ftp:` fallen
* damit heraus.
*/
@ValidatorConstraint({ name: 'nurHttpsOhneZugangsdaten', async: false })
class NurHttpsOhneZugangsdatenConstraint implements ValidatorConstraintInterface {
validate(value: unknown): boolean {
if (typeof value !== 'string') return false;
let parsed: URL;
try {
parsed = new URL(value);
} catch {
return false;
}
return (
parsed.protocol === 'https:' &&
parsed.hostname !== '' &&
parsed.username === '' &&
parsed.password === ''
);
}
defaultMessage(): string {
return 'Nur https-Adressen ohne Zugangsdaten sind erlaubt.';
}
}
const trimString = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
/** DTO fuer das Anlegen eines eigenen Moduls. */
export class CreateCustomModuleDto {
@Transform(trimString)
@IsString()
@IsNotEmpty()
@MaxLength(100)
name!: string;
@Transform(trimString)
@IsString()
@MaxLength(2048)
@Validate(NurHttpsOhneZugangsdatenConstraint)
url!: string;
@IsIn([...MODULE_CATEGORIES])
category!: (typeof MODULE_CATEGORIES)[number];
}
/** Teil-Update: jedes gesetzte Feld wird genauso geprueft wie beim Anlegen. */
export class UpdateCustomModuleDto extends PartialType(CreateCustomModuleDto) {}