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:
@@ -0,0 +1,47 @@
|
|||||||
|
-- 260929-9wc — Eigene Module: externe Seiten als Seitenleisten-Eintraege.
|
||||||
|
--
|
||||||
|
-- Zweck: neue Tabelle "CustomModule". Der Administrator legt Eintraege an
|
||||||
|
-- (Name, https-Adresse, Kategorie), alle Benutzer des Mandanten sehen sie in
|
||||||
|
-- der Seitenleiste und ein Klick zeigt die Seite im Rahmen. Mehrere Zeilen je
|
||||||
|
-- Mandant, Vorbild "ProxmoxServer" (tenantId-Spalte, keine Relation zu
|
||||||
|
-- Tenant).
|
||||||
|
--
|
||||||
|
-- Von Hand geschrieben (Vorbild 20260923140000_proxmox_server), von Hand
|
||||||
|
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
|
||||||
|
--
|
||||||
|
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): die
|
||||||
|
-- Tabelle traegt `tenantId` und `tenant_isolation_policy` OHNE
|
||||||
|
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`) — eigene
|
||||||
|
-- Module sind Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines
|
||||||
|
-- einzelnen Benutzers.
|
||||||
|
--
|
||||||
|
-- BEWUSST KEINE `system_read_policy`: es gibt keinen Hintergrunddienst, der
|
||||||
|
-- eigene Module ueber alle Mandanten lesen muesste; jeder Zugriff laeuft
|
||||||
|
-- mandantengebunden ueber `forTenant(prisma, tenantId)`.
|
||||||
|
--
|
||||||
|
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
|
||||||
|
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
|
||||||
|
-- tun.
|
||||||
|
--
|
||||||
|
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
|
||||||
|
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
|
||||||
|
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
|
||||||
|
|
||||||
|
CREATE TABLE "CustomModule" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"category" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "CustomModule_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "CustomModule_tenantId_idx" ON "CustomModule"("tenantId");
|
||||||
|
|
||||||
|
ALTER TABLE "CustomModule" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "CustomModule" FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_policy ON "CustomModule"
|
||||||
|
USING ("tenantId" = current_tenant_id());
|
||||||
@@ -716,3 +716,19 @@ model ProxmoxServerStatus {
|
|||||||
|
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Eigene Module (quick-260929-9wc): vom Administrator angelegte Seitenleisten-
|
||||||
|
// Eintraege, die eine externe https-Seite im Rahmen zeigen. Sichtbar fuer alle
|
||||||
|
// Benutzer des Mandanten. Zeilenschutz nach Muster ProxmoxServer (tenantId,
|
||||||
|
// keine Relation zu Tenant).
|
||||||
|
model CustomModule {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
name String
|
||||||
|
url String
|
||||||
|
category String // eine der MODULE_CATEGORIES aus @tessera/shared
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { TenantModule } from './tenant/tenant.module';
|
|||||||
import { TendersModule } from './tenders/tenders.module';
|
import { TendersModule } from './tenders/tenders.module';
|
||||||
import { UserModule } from './user/user.module';
|
import { UserModule } from './user/user.module';
|
||||||
import { ProxmoxModule } from './proxmox/proxmox.module';
|
import { ProxmoxModule } from './proxmox/proxmox.module';
|
||||||
|
import { CustomModulesModule } from './custom-modules/custom-modules.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -53,6 +54,7 @@ import { ProxmoxModule } from './proxmox/proxmox.module';
|
|||||||
TendersModule,
|
TendersModule,
|
||||||
BugReportsModule,
|
BugReportsModule,
|
||||||
ProxmoxModule,
|
ProxmoxModule,
|
||||||
|
CustomModulesModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Global JWT guard: all routes require auth unless @Public()
|
// Global JWT guard: all routes require auth unless @Public()
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { ForbiddenException, ValidationPipe } from '@nestjs/common';
|
||||||
|
import { Role } from '@prisma/client';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||||
|
import { CustomModulesController } from './custom-modules.controller';
|
||||||
|
import { CreateCustomModuleDto } from './dto/custom-module.dto';
|
||||||
|
|
||||||
|
function makeService() {
|
||||||
|
return {
|
||||||
|
list: vi.fn(async (..._args: unknown[]) => []),
|
||||||
|
getOne: vi.fn(async (..._args: unknown[]) => ({})),
|
||||||
|
create: vi.fn(async (..._args: unknown[]) => ({})),
|
||||||
|
update: vi.fn(async (..._args: unknown[]) => ({})),
|
||||||
|
remove: vi.fn(async (..._args: unknown[]) => ({ deleted: true })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = (tenantId?: string) => ({ tenantId }) as any;
|
||||||
|
const proto = CustomModulesController.prototype as any;
|
||||||
|
|
||||||
|
describe('CustomModulesController — Rollen (T-9WC-01)', () => {
|
||||||
|
it.each(['create', 'update', 'remove'])('%s ist nur fuer ADMIN und SUPER_ADMIN offen', (name) => {
|
||||||
|
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toEqual([Role.ADMIN, Role.SUPER_ADMIN]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['list', 'getOne'])('%s traegt keine Rollen (jeder Angemeldete)', (name) => {
|
||||||
|
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('haengt an Pfad custom-modules', () => {
|
||||||
|
expect(Reflect.getMetadata('path', CustomModulesController)).toBe('custom-modules');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CustomModulesController — Mandant', () => {
|
||||||
|
it('reicht req.tenantId an den Dienst weiter', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
const controller = new CustomModulesController(service as any);
|
||||||
|
await controller.list(req('t1'));
|
||||||
|
await controller.getOne(req('t1'), 'x');
|
||||||
|
await controller.create(req('t1'), { name: 'a', url: 'https://a.de', category: 'fleet' });
|
||||||
|
await controller.update(req('t1'), 'x', { name: 'b' });
|
||||||
|
await controller.remove(req('t1'), 'x');
|
||||||
|
expect(service.list).toHaveBeenCalledWith('t1');
|
||||||
|
expect(service.getOne).toHaveBeenCalledWith('t1', 'x');
|
||||||
|
expect(service.create.mock.calls[0][0]).toBe('t1');
|
||||||
|
expect(service.update.mock.calls[0].slice(0, 2)).toEqual(['t1', 'x']);
|
||||||
|
expect(service.remove).toHaveBeenCalledWith('t1', 'x');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wirft ForbiddenException ohne req.tenantId', async () => {
|
||||||
|
const controller = new CustomModulesController(makeService() as any);
|
||||||
|
await expect(controller.list(req())).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
await expect(controller.getOne(req(), 'x')).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
await expect(
|
||||||
|
controller.create(req(), { name: 'a', url: 'https://a.de', category: 'fleet' }),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
await expect(controller.remove(req(), 'x')).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die globale Pipe verwirft ein untergeschobenes tenantId (T-9WC-07)', async () => {
|
||||||
|
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||||
|
const out: any = await pipe.transform(
|
||||||
|
{ name: 'a', url: 'https://a.de', category: 'fleet', tenantId: 'evil' },
|
||||||
|
{ type: 'body', metatype: CreateCustomModuleDto },
|
||||||
|
);
|
||||||
|
expect(out).not.toHaveProperty('tenantId');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CustomModulesController — Routen-Reihenfolge (statisch vor :id)', () => {
|
||||||
|
it('deklariert list vor getOne', () => {
|
||||||
|
const methods = Object.getOwnPropertyNames(CustomModulesController.prototype);
|
||||||
|
const listIdx = methods.indexOf('list');
|
||||||
|
const idIdx = methods.indexOf('getOne');
|
||||||
|
expect(listIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(idIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(listIdx).toBeLessThan(idIdx);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Role } from '@prisma/client';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||||
|
import { CustomModulesService } from './custom-modules.service';
|
||||||
|
import { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eigene Module (quick-260929-9wc). Lesen (`GET`, `GET :id`) steht jedem
|
||||||
|
* angemeldeten Benutzer offen — die Eintraege sind fuer alle sichtbar (D-01);
|
||||||
|
* Schreiben nur `@Roles(ADMIN, SUPER_ADMIN)` (T-9WC-01). Kein `@UseModule`:
|
||||||
|
* eigene Module haengen an keiner Modul-Aktivierung. `tenantId` kommt
|
||||||
|
* ausschliesslich aus `req.tenantId` (gesetzt vom `TenantGuard`).
|
||||||
|
*
|
||||||
|
* ROUTEN-REIHENFOLGE: NestJS bildet Routen in Deklarationsreihenfolge ab.
|
||||||
|
* Jede kuenftige statische GET-Route MUSS ueber `getOne` (`@Get(':id')`)
|
||||||
|
* stehen, sonst faengt `:id` sie ab (404-Shadowing); der Controller-Test
|
||||||
|
* haelt die Reihenfolge von `list` vor `getOne` fest.
|
||||||
|
*/
|
||||||
|
@Controller('custom-modules')
|
||||||
|
export class CustomModulesController {
|
||||||
|
constructor(private readonly service: CustomModulesService) {}
|
||||||
|
|
||||||
|
private requireTenantId(req: AuthenticatedRequest): string {
|
||||||
|
const tenantId = req.tenantId;
|
||||||
|
if (!tenantId) {
|
||||||
|
throw new ForbiddenException('Kein Mandantenkontext');
|
||||||
|
}
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async list(@Req() req: AuthenticatedRequest) {
|
||||||
|
return this.service.list(this.requireTenantId(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
async getOne(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||||
|
return this.service.getOne(this.requireTenantId(req), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async create(@Req() req: AuthenticatedRequest, @Body() dto: CreateCustomModuleDto) {
|
||||||
|
return this.service.create(this.requireTenantId(req), dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async update(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateCustomModuleDto,
|
||||||
|
) {
|
||||||
|
return this.service.update(this.requireTenantId(req), id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async remove(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||||
|
return this.service.remove(this.requireTenantId(req), id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CustomModulesController } from './custom-modules.controller';
|
||||||
|
import { CustomModulesService } from './custom-modules.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eigene Module (quick-260929-9wc). `PrismaModule` ist global (wie bei
|
||||||
|
* `ProxmoxModule`, das PrismaService ebenfalls ohne eigenen Import erhaelt).
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
controllers: [CustomModulesController],
|
||||||
|
providers: [CustomModulesService],
|
||||||
|
})
|
||||||
|
export class CustomModulesModule {}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// `forTenant` reicht den Klienten durch — Mandantenbindung selbst prueft
|
||||||
|
// rls-access-inventory.spec.ts; hier zaehlt, dass je Methode (prisma, tenantId)
|
||||||
|
// uebergeben wird.
|
||||||
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||||
|
forTenant: vi.fn((p: unknown) => p),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
|
import { CustomModulesService } from './custom-modules.service';
|
||||||
|
|
||||||
|
function makeFakePrisma() {
|
||||||
|
const rows = new Map<string, any>();
|
||||||
|
let seq = 0;
|
||||||
|
const customModule = {
|
||||||
|
create: vi.fn(async ({ data }: { data: any }) => {
|
||||||
|
const id = `cm-${++seq}`;
|
||||||
|
const row = { id, createdAt: new Date(), updatedAt: new Date(), ...data };
|
||||||
|
rows.set(id, row);
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
findMany: vi.fn(async ({ where, orderBy }: { where?: any; orderBy?: any } = {}) => {
|
||||||
|
let list = [...rows.values()];
|
||||||
|
if (where?.tenantId) list = list.filter((r) => r.tenantId === where.tenantId);
|
||||||
|
if (orderBy?.name === 'asc') list.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
return list;
|
||||||
|
}),
|
||||||
|
findUnique: vi.fn(async ({ where }: { where: { id: string } }) => rows.get(where.id) ?? null),
|
||||||
|
update: vi.fn(async ({ where, data }: { where: { id: string }; data: any }) => {
|
||||||
|
const row = { ...rows.get(where.id), ...data };
|
||||||
|
rows.set(where.id, row);
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
delete: vi.fn(async ({ where }: { where: { id: string } }) => {
|
||||||
|
rows.delete(where.id);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return { customModule, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto = { name: 'Wiki', url: 'https://example.com', category: 'infrastructure' as const };
|
||||||
|
|
||||||
|
describe('CustomModulesService', () => {
|
||||||
|
it('create speichert tenantId aus dem Argument, nie aus dem DTO', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new CustomModulesService(prisma as any);
|
||||||
|
await service.create('t1', { ...dto, tenantId: 'evil' } as any);
|
||||||
|
expect(prisma.customModule.create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prisma.customModule.create.mock.calls[0][0].data.tenantId).toBe('t1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('list liefert nur Zeilen des Mandanten, nach Name sortiert', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new CustomModulesService(prisma as any);
|
||||||
|
await service.create('t1', { ...dto, name: 'Zebra' });
|
||||||
|
await service.create('t1', { ...dto, name: 'Anker' });
|
||||||
|
await service.create('t2', { ...dto, name: 'Fremd' });
|
||||||
|
const result = await service.list('t1');
|
||||||
|
expect(result.map((r: any) => r.name)).toEqual(['Anker', 'Zebra']);
|
||||||
|
expect(prisma.customModule.findMany.mock.calls[0]?.[0]?.where).toEqual({ tenantId: 't1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getOne liefert die Zeile ohne tenantId', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new CustomModulesService(prisma as any);
|
||||||
|
const created: any = await service.create('t1', dto);
|
||||||
|
const row: any = await service.getOne('t1', created.id);
|
||||||
|
expect(row.name).toBe('Wiki');
|
||||||
|
expect(row).not.toHaveProperty('tenantId');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getOne/update/remove mit unbekannter id -> NotFoundException', async () => {
|
||||||
|
const service = new CustomModulesService(makeFakePrisma() as any);
|
||||||
|
await expect(service.getOne('t1', 'nope')).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
await expect(service.update('t1', 'nope', { name: 'x' })).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
await expect(service.remove('t1', 'nope')).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getOne/update/remove mit Zeile eines anderen Mandanten -> NotFoundException', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new CustomModulesService(prisma as any);
|
||||||
|
const created: any = await service.create('t2', dto);
|
||||||
|
await expect(service.getOne('t1', created.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
await expect(service.update('t1', created.id, { name: 'x' })).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
await expect(service.remove('t1', created.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
expect(prisma.customModule.update).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.customModule.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update aendert nur gesetzte Felder', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new CustomModulesService(prisma as any);
|
||||||
|
const created: any = await service.create('t1', dto);
|
||||||
|
await service.update('t1', created.id, { name: 'Neu' });
|
||||||
|
expect(prisma.customModule.update.mock.calls[0][0].data).toEqual({ name: 'Neu' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove loescht und liefert { deleted: true }', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new CustomModulesService(prisma as any);
|
||||||
|
const created: any = await service.create('t1', dto);
|
||||||
|
await expect(service.remove('t1', created.id)).resolves.toEqual({ deleted: true });
|
||||||
|
expect(prisma.rows.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ruft forTenant je Methode mit (prisma, tenantId) auf', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new CustomModulesService(prisma as any);
|
||||||
|
vi.mocked(forTenant).mockClear();
|
||||||
|
const created: any = await service.create('t1', dto);
|
||||||
|
await service.list('t1');
|
||||||
|
await service.getOne('t1', created.id);
|
||||||
|
await service.update('t1', created.id, { name: 'a' });
|
||||||
|
await service.remove('t1', created.id);
|
||||||
|
expect(forTenant).toHaveBeenCalledTimes(5);
|
||||||
|
for (const call of vi.mocked(forTenant).mock.calls) {
|
||||||
|
expect(call).toEqual([prisma, 't1']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
|
import type { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
|
||||||
|
|
||||||
|
/** Antwortfelder — genau diese, nichts anderes verlaesst den Dienst. */
|
||||||
|
const CUSTOM_MODULE_SELECT = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
url: true,
|
||||||
|
category: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eigene Module (quick-260929-9wc): vom Administrator angelegte
|
||||||
|
* Seitenleisten-Eintraege mit externer https-Adresse. `tenantId` kommt
|
||||||
|
* ausschliesslich als Argument (aus `req.tenantId`), nie aus dem DTO. Je
|
||||||
|
* Methode ein eigener `forTenant`-Klient; zusaetzlich pruefen
|
||||||
|
* getOne/update/remove `row.tenantId` — zweites Netz, solange der
|
||||||
|
* RLS-Schalter aus ist (Muster DashboardImage). Eine fremde oder unbekannte
|
||||||
|
* id ergibt immer `NotFoundException`, nie einen Hinweis auf die Existenz.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CustomModulesService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(tenantId: string) {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
return tenantPrisma.customModule.findMany({
|
||||||
|
where: { tenantId },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
select: CUSTOM_MODULE_SELECT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOne(tenantId: string, id: string) {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
const row = await tenantPrisma.customModule.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { ...CUSTOM_MODULE_SELECT, tenantId: true },
|
||||||
|
});
|
||||||
|
if (!row || row.tenantId !== tenantId) {
|
||||||
|
throw new NotFoundException('Eigenes Modul nicht gefunden');
|
||||||
|
}
|
||||||
|
const { tenantId: _omit, ...result } = row;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(tenantId: string, dto: CreateCustomModuleDto) {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
return tenantPrisma.customModule.create({
|
||||||
|
data: { tenantId, name: dto.name, url: dto.url, category: dto.category },
|
||||||
|
select: CUSTOM_MODULE_SELECT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(tenantId: string, id: string, dto: UpdateCustomModuleDto) {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
const existing = await tenantPrisma.customModule.findUnique({ where: { id } });
|
||||||
|
if (!existing || existing.tenantId !== tenantId) {
|
||||||
|
throw new NotFoundException('Eigenes Modul nicht gefunden');
|
||||||
|
}
|
||||||
|
const data: { name?: string; url?: string; category?: string } = {};
|
||||||
|
if (dto.name !== undefined) data.name = dto.name;
|
||||||
|
if (dto.url !== undefined) data.url = dto.url;
|
||||||
|
if (dto.category !== undefined) data.category = dto.category;
|
||||||
|
return tenantPrisma.customModule.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
select: CUSTOM_MODULE_SELECT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(tenantId: string, id: string) {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
const existing = await tenantPrisma.customModule.findUnique({ where: { id } });
|
||||||
|
if (!existing || existing.tenantId !== tenantId) {
|
||||||
|
throw new NotFoundException('Eigenes Modul nicht gefunden');
|
||||||
|
}
|
||||||
|
await tenantPrisma.customModule.delete({ where: { id } });
|
||||||
|
return { deleted: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { plainToInstance } from 'class-transformer';
|
||||||
|
import { validate } from 'class-validator';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CreateCustomModuleDto, UpdateCustomModuleDto } from './custom-module.dto';
|
||||||
|
|
||||||
|
async function errorsFor<T extends object>(cls: new () => T, plain: Record<string, unknown>) {
|
||||||
|
const dto = plainToInstance(cls, plain);
|
||||||
|
const errors = await validate(dto as object);
|
||||||
|
return errors.map((e) => e.property);
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = { name: 'Wiki', url: 'https://example.com', category: 'infrastructure' };
|
||||||
|
|
||||||
|
describe('CreateCustomModuleDto', () => {
|
||||||
|
it('nimmt einen gueltigen Eintrag an', async () => {
|
||||||
|
expect(await errorsFor(CreateCustomModuleDto, valid)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'http://example.com',
|
||||||
|
'javascript:alert(1)',
|
||||||
|
'data:text/html,x',
|
||||||
|
'ftp://x',
|
||||||
|
'kaputt',
|
||||||
|
'https://user:pw@example.com',
|
||||||
|
'https://user@example.com',
|
||||||
|
])('lehnt die Adresse %s ab', async (url) => {
|
||||||
|
expect(await errorsFor(CreateCustomModuleDto, { ...valid, url })).toContain('url');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt eine unbekannte Kategorie ab', async () => {
|
||||||
|
expect(await errorsFor(CreateCustomModuleDto, { ...valid, category: 'other' })).toContain(
|
||||||
|
'category',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['', ' '])('lehnt den Namen %j ab', async (name) => {
|
||||||
|
expect(await errorsFor(CreateCustomModuleDto, { ...valid, name })).toContain('name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trimmt den Namen', () => {
|
||||||
|
const dto = plainToInstance(CreateCustomModuleDto, { ...valid, name: ' Wiki ' });
|
||||||
|
expect(dto.name).toBe('Wiki');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt zu lange Namen und Adressen ab', async () => {
|
||||||
|
expect(await errorsFor(CreateCustomModuleDto, { ...valid, name: 'a'.repeat(101) })).toContain(
|
||||||
|
'name',
|
||||||
|
);
|
||||||
|
const longUrl = `https://example.com/${'a'.repeat(2048)}`;
|
||||||
|
expect(await errorsFor(CreateCustomModuleDto, { ...valid, url: longUrl })).toContain('url');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UpdateCustomModuleDto', () => {
|
||||||
|
it('akzeptiert Teilmengen', async () => {
|
||||||
|
expect(await errorsFor(UpdateCustomModuleDto, { name: 'Neu' })).toEqual([]);
|
||||||
|
expect(await errorsFor(UpdateCustomModuleDto, {})).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prueft jedes gesetzte Feld gleich', async () => {
|
||||||
|
expect(await errorsFor(UpdateCustomModuleDto, { url: 'http://example.com' })).toContain('url');
|
||||||
|
expect(await errorsFor(UpdateCustomModuleDto, { category: 'other' })).toContain('category');
|
||||||
|
expect(await errorsFor(UpdateCustomModuleDto, { name: ' ' })).toContain('name');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) {}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { CustomModuleView } from '@/components/modules/custom-module-view';
|
||||||
|
|
||||||
|
interface CustomModulePageProps {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eigenes Modul (quick-260929-9wc): externe https-Seite im Rahmen.
|
||||||
|
* Bewusst OHNE ModuleAccessGate — eigene Module sind fuer alle angemeldeten
|
||||||
|
* Benutzer sichtbar (D-01); der statische Ordner `custom` hat im App Router
|
||||||
|
* Vorrang vor `[category]/[moduleSlug]`.
|
||||||
|
*/
|
||||||
|
export default async function CustomModulePage({ params }: CustomModulePageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
return <CustomModuleView id={id} />;
|
||||||
|
}
|
||||||
@@ -74,6 +74,20 @@ vi.mock('@/components/layout/app-version-badge', () => ({
|
|||||||
AppVersionBadge: () => <div data-testid="app-version-badge" />,
|
AppVersionBadge: () => <div data-testid="app-version-badge" />,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Eigene Module (quick-260929-9wc): eigener Client, Standard = leere Liste,
|
||||||
|
// damit die Abruf-Zaehler-Tests auf `fetch` unveraendert gelten.
|
||||||
|
let mockCustomModules: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
category: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}[] = [];
|
||||||
|
vi.mock('@/lib/custom-modules-api', () => ({
|
||||||
|
listCustomModules: vi.fn(() => Promise.resolve(mockCustomModules)),
|
||||||
|
}));
|
||||||
|
|
||||||
const mockActiveModules = [
|
const mockActiveModules = [
|
||||||
{ id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'Domain-Tools' },
|
{ id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'Domain-Tools' },
|
||||||
{ id: 'm2', slug: 'converter', name: 'Converter', category: 'Utilities' },
|
{ id: 'm2', slug: 'converter', name: 'Converter', category: 'Utilities' },
|
||||||
@@ -93,8 +107,21 @@ afterEach(() => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
mockPathname = '/';
|
mockPathname = '/';
|
||||||
mockRefreshKey = 0;
|
mockRefreshKey = 0;
|
||||||
|
mockCustomModules = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function customModule(overrides: Partial<(typeof mockCustomModules)[number]> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'cm-1',
|
||||||
|
name: 'Wiki',
|
||||||
|
url: 'https://wiki.example.com',
|
||||||
|
category: 'infrastructure',
|
||||||
|
createdAt: '2026-09-29T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-09-29T00:00:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function importSidebar() {
|
async function importSidebar() {
|
||||||
const mod = await import('./sidebar');
|
const mod = await import('./sidebar');
|
||||||
return mod.Sidebar;
|
return mod.Sidebar;
|
||||||
@@ -243,6 +270,85 @@ describe('Sidebar', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('zeigt ein eigenes Modul unter seiner Kategorie als Link auf /modules/custom/<id>', async () => {
|
||||||
|
mockCustomModules = [customModule()];
|
||||||
|
const Sidebar = await importSidebar();
|
||||||
|
render(<Sidebar />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Wiki')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
const link = screen.getByText('Wiki').closest('a');
|
||||||
|
expect(link).toHaveAttribute('href', '/modules/custom/cm-1');
|
||||||
|
expect(link?.querySelector('[data-module-tile="custom"]')).not.toBeNull();
|
||||||
|
// Kategorie-Kopf "infrastructure" (Test-Attrappe ohne Uebersetzung -> Kennung)
|
||||||
|
const header = screen.getByText('infrastructure').closest('li');
|
||||||
|
expect(header?.contains(link)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt eine Kategorie, die nur eigene Module hat', async () => {
|
||||||
|
mockCustomModules = [customModule({ category: 'fleet' })];
|
||||||
|
const Sidebar = await importSidebar();
|
||||||
|
render(<Sidebar />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('fleet')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Wiki')).toBeInTheDocument();
|
||||||
|
// Die eingebauten Kategorien bleiben daneben stehen.
|
||||||
|
expect(screen.getByText('Domain-Tools')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eingebaute Module stehen innerhalb einer Kategorie vor eigenen', async () => {
|
||||||
|
mockCustomModules = [customModule({ category: 'Domain-Tools', name: 'Aaa Eigenes' })];
|
||||||
|
const Sidebar = await importSidebar();
|
||||||
|
render(<Sidebar />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Aaa Eigenes')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
const items = screen
|
||||||
|
.getByText('Domain-Tools')
|
||||||
|
.closest('li')
|
||||||
|
?.querySelectorAll('a span.truncate');
|
||||||
|
expect(Array.from(items ?? []).map((n) => n.textContent)).toEqual(['Domaincheck', 'Aaa Eigenes']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auf /modules/custom/<id> traegt genau dieser Eintrag die Auswahlmarke', async () => {
|
||||||
|
mockCustomModules = [customModule(), customModule({ id: 'cm-2', name: 'Anderes' })];
|
||||||
|
mockPathname = '/modules/custom/cm-1';
|
||||||
|
const Sidebar = await importSidebar();
|
||||||
|
render(<Sidebar />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Wiki')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.getByText('Wiki').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
screen.getByText('Anderes').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
screen.getByText('Domaincheck').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die Suche findet ein eigenes Modul', async () => {
|
||||||
|
mockCustomModules = [customModule()];
|
||||||
|
const Sidebar = await importSidebar();
|
||||||
|
render(<Sidebar />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Wiki')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByPlaceholderText('Module suchen...'), 'wik');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Domaincheck')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Wiki')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders the version badge below the navigation', async () => {
|
it('renders the version badge below the navigation', async () => {
|
||||||
const Sidebar = await importSidebar();
|
const Sidebar = await importSidebar();
|
||||||
render(<Sidebar />);
|
render(<Sidebar />);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { ModuleTile } from '@/components/modules/module-tile';
|
|||||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||||
import { useNavStore } from '@/lib/stores/nav-store';
|
import { useNavStore } from '@/lib/stores/nav-store';
|
||||||
import { DashboardGreeting } from '@/components/dashboard/dashboard-greeting';
|
import { DashboardGreeting } from '@/components/dashboard/dashboard-greeting';
|
||||||
|
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
|
||||||
|
|
||||||
/** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */
|
/** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */
|
||||||
function SelectionPill() {
|
function SelectionPill() {
|
||||||
@@ -34,11 +35,50 @@ interface SidebarModule {
|
|||||||
category: string;
|
category: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Eintrag der Seitenleiste: eingebautes Modul ODER eigenes Modul
|
||||||
|
* (quick-260929-9wc). Beide laufen durch dieselbe Gruppierung, Suche und
|
||||||
|
* Kachelliste; sie unterscheiden sich nur in Ziel und Kachelsymbol.
|
||||||
|
*/
|
||||||
|
interface SidebarEntry {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
href: string;
|
||||||
|
/** Kennung fuer das Kachelsymbol; `custom` hat kein eigenes Symbol (allgemeine Kachel). */
|
||||||
|
tileSlug: string;
|
||||||
|
/** Weitere Pfade, unter denen der Eintrag als ausgewaehlt gilt. */
|
||||||
|
extraActiveHref?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moduleEntry(mod: SidebarModule): SidebarEntry {
|
||||||
|
const category = mod.category || 'other';
|
||||||
|
return {
|
||||||
|
key: mod.id,
|
||||||
|
name: mod.name,
|
||||||
|
category,
|
||||||
|
href: `/modules/${category}/${mod.slug}`,
|
||||||
|
tileSlug: mod.slug,
|
||||||
|
extraActiveHref: `/modules/${mod.slug}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function customEntry(mod: CustomModule): SidebarEntry {
|
||||||
|
return {
|
||||||
|
key: `custom-${mod.id}`,
|
||||||
|
name: mod.name,
|
||||||
|
category: mod.category || 'other',
|
||||||
|
href: `/modules/custom/${mod.id}`,
|
||||||
|
tileSlug: 'custom',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const t = useTranslations('sidebar');
|
const t = useTranslations('sidebar');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const { isCollapsed, isMobileOpen, toggle, setMobileOpen } = useSidebarStore();
|
const { isCollapsed, isMobileOpen, toggle, setMobileOpen } = useSidebarStore();
|
||||||
const [activeModules, setActiveModules] = useState<SidebarModule[]>([]);
|
const [activeModules, setActiveModules] = useState<SidebarModule[]>([]);
|
||||||
|
const [customModules, setCustomModules] = useState<CustomModule[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
// Kategorien sind anfangs aufgeklappt (Design „Mosaik“): die Modul-Kacheln
|
// Kategorien sind anfangs aufgeklappt (Design „Mosaik“): die Modul-Kacheln
|
||||||
// sind der wichtigste Wegweiser. Gemerkt werden die ZUGEKLAPPTEN.
|
// sind der wichtigste Wegweiser. Gemerkt werden die ZUGEKLAPPTEN.
|
||||||
@@ -67,6 +107,9 @@ export function Sidebar() {
|
|||||||
|
|
||||||
const linkClass = (href: string) => itemClass(isActive(href));
|
const linkClass = (href: string) => itemClass(isActive(href));
|
||||||
|
|
||||||
|
const isEntryActive = (entry: SidebarEntry) =>
|
||||||
|
isActive(entry.href) || (entry.extraActiveHref !== undefined && isActive(entry.extraActiveHref));
|
||||||
|
|
||||||
// Moduleintraege unter einer Kategorie eine Stufe kleiner als die
|
// Moduleintraege unter einer Kategorie eine Stufe kleiner als die
|
||||||
// Hauptpunkte (quick-260928-wtt): 32 statt 36 px hoch, 13 statt 14 px Schrift;
|
// Hauptpunkte (quick-260928-wtt): 32 statt 36 px hoch, 13 statt 14 px Schrift;
|
||||||
// die 20-px-Modulkachel passt weiter hinein.
|
// die 20-px-Modulkachel passt weiter hinein.
|
||||||
@@ -74,19 +117,50 @@ export function Sidebar() {
|
|||||||
itemClass(active).replace('h-9', 'h-8').replace('gap-3', 'gap-2.5').replace('text-sm', 'text-[13px]');
|
itemClass(active).replace('h-9', 'h-8').replace('gap-3', 'gap-2.5').replace('text-sm', 'text-[13px]');
|
||||||
|
|
||||||
const fetchActiveModules = useCallback(async () => {
|
const fetchActiveModules = useCallback(async () => {
|
||||||
|
const loadBuiltIn = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/modules/active`, {
|
const res = await fetch(`${API_URL}/modules/active`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const modules: SidebarModule[] = await res.json();
|
setActiveModules(await res.json());
|
||||||
setActiveModules(modules);
|
|
||||||
setNavModules(modules);
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// silently fail
|
// silently fail
|
||||||
}
|
}
|
||||||
}, [setNavModules]);
|
};
|
||||||
|
// Eigene Module (quick-260929-9wc) im selben Auffrisch-Takt; ein Fehler
|
||||||
|
// laesst die Leiste wie beim Modulabruf still beim bisherigen Stand.
|
||||||
|
const loadCustom = async () => {
|
||||||
|
try {
|
||||||
|
setCustomModules(await listCustomModules());
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await Promise.all([loadBuiltIn(), loadCustom()]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Die Kopfzeile liest den Seitentitel aus dem geteilten Stand; eigene
|
||||||
|
// Module gehen mit slug = id hinein, damit `resolvePageTitle` das
|
||||||
|
// Pfadsegment /modules/custom/<id> ohne Aenderung findet.
|
||||||
|
useEffect(() => {
|
||||||
|
setNavModules([
|
||||||
|
...activeModules,
|
||||||
|
...customModules.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
slug: m.id,
|
||||||
|
name: m.name,
|
||||||
|
category: m.category,
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
}, [activeModules, customModules, setNavModules]);
|
||||||
|
|
||||||
|
// Eingebaute Module stehen innerhalb einer Kategorie vor eigenen.
|
||||||
|
const entries = useMemo<SidebarEntry[]>(
|
||||||
|
() => [...activeModules.map(moduleEntry), ...customModules.map(customEntry)],
|
||||||
|
[activeModules, customModules],
|
||||||
|
);
|
||||||
|
|
||||||
// Befund 17 (quick-260921-gof): sidebarRefreshKey ist der Auffrisch-
|
// Befund 17 (quick-260921-gof): sidebarRefreshKey ist der Auffrisch-
|
||||||
// Ausloeser aus dem Marketplace-Speicher — ohne ihn erscheint ein frisch
|
// Ausloeser aus dem Marketplace-Speicher — ohne ihn erscheint ein frisch
|
||||||
@@ -107,17 +181,16 @@ export function Sidebar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const filteredCategories = useMemo(() => {
|
const filteredCategories = useMemo(() => {
|
||||||
const categories = new Map<string, SidebarModule[]>();
|
const categories = new Map<string, SidebarEntry[]>();
|
||||||
for (const mod of activeModules) {
|
for (const entry of entries) {
|
||||||
const cat = mod.category || 'other';
|
if (!categories.has(entry.category)) categories.set(entry.category, []);
|
||||||
if (!categories.has(cat)) categories.set(cat, []);
|
categories.get(entry.category)!.push(entry);
|
||||||
categories.get(cat)!.push(mod);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!searchQuery) return Array.from(categories.entries());
|
if (!searchQuery) return Array.from(categories.entries());
|
||||||
|
|
||||||
const q = searchQuery.toLowerCase();
|
const q = searchQuery.toLowerCase();
|
||||||
const result: [string, SidebarModule[]][] = [];
|
const result: [string, SidebarEntry[]][] = [];
|
||||||
for (const [cat, mods] of categories.entries()) {
|
for (const [cat, mods] of categories.entries()) {
|
||||||
if (cat.toLowerCase().includes(q) || categoryLabel(cat).toLowerCase().includes(q)) {
|
if (cat.toLowerCase().includes(q) || categoryLabel(cat).toLowerCase().includes(q)) {
|
||||||
result.push([cat, mods]);
|
result.push([cat, mods]);
|
||||||
@@ -127,7 +200,7 @@ export function Sidebar() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [activeModules, searchQuery, categoryLabel]);
|
}, [entries, searchQuery, categoryLabel]);
|
||||||
|
|
||||||
const navIcon = (children: React.ReactNode) => (
|
const navIcon = (children: React.ReactNode) => (
|
||||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||||
@@ -168,16 +241,15 @@ export function Sidebar() {
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{/* Eingeklappt: nur die Modul-Kacheln untereinander (Name als Tooltip). */}
|
{/* Eingeklappt: nur die Modul-Kacheln untereinander (Name als Tooltip). */}
|
||||||
{isCollapsed && activeModules.length > 0 && (
|
{isCollapsed && entries.length > 0 && (
|
||||||
<ul className="mt-3 flex flex-col gap-0.5 border-t border-sidebar-border pt-3">
|
<ul className="mt-3 flex flex-col gap-0.5 border-t border-sidebar-border pt-3">
|
||||||
{activeModules.map((mod) => {
|
{entries.map((entry) => {
|
||||||
const href = `/modules/${mod.category || 'other'}/${mod.slug}`;
|
const active = isEntryActive(entry);
|
||||||
const active = isActive(href) || isActive(`/modules/${mod.slug}`);
|
|
||||||
return (
|
return (
|
||||||
<li key={mod.id}>
|
<li key={entry.key}>
|
||||||
<Link href={href} className={itemClass(active)} aria-label={mod.name} title={mod.name}>
|
<Link href={entry.href} className={itemClass(active)} aria-label={entry.name} title={entry.name}>
|
||||||
{active && <SelectionPill />}
|
{active && <SelectionPill />}
|
||||||
<ModuleTile slug={mod.slug} size={20} tone={active ? 'accent' : 'neutral'} />
|
<ModuleTile slug={entry.tileSlug} size={20} tone={active ? 'accent' : 'neutral'} />
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -192,7 +264,7 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-1" aria-label={t('categories.label')} role="group">
|
<div className="mt-1" aria-label={t('categories.label')} role="group">
|
||||||
{activeModules.length === 0 ? (
|
{entries.length === 0 ? (
|
||||||
<div className="px-2.5 py-2 text-sm text-muted-foreground">
|
<div className="px-2.5 py-2 text-sm text-muted-foreground">
|
||||||
{t('categories.empty')}
|
{t('categories.empty')}
|
||||||
</div>
|
</div>
|
||||||
@@ -202,7 +274,7 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="flex flex-col gap-2">
|
<ul className="flex flex-col gap-2">
|
||||||
{filteredCategories.map(([category, mods]) => {
|
{filteredCategories.map(([category, categoryEntries]) => {
|
||||||
// Bei aktiver Suche immer aufgeklappt, sonst nach Wahl.
|
// Bei aktiver Suche immer aufgeklappt, sonst nach Wahl.
|
||||||
const open = searchQuery !== '' || !closedCategories.has(category);
|
const open = searchQuery !== '' || !closedCategories.has(category);
|
||||||
return (
|
return (
|
||||||
@@ -220,15 +292,14 @@ export function Sidebar() {
|
|||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<ul className="mt-0.5 flex flex-col gap-0.5">
|
<ul className="mt-0.5 flex flex-col gap-0.5">
|
||||||
{mods.map((mod) => {
|
{categoryEntries.map((entry) => {
|
||||||
const href = `/modules/${category}/${mod.slug}`;
|
const active = isEntryActive(entry);
|
||||||
const active = isActive(href) || isActive(`/modules/${mod.slug}`);
|
|
||||||
return (
|
return (
|
||||||
<li key={mod.id}>
|
<li key={entry.key}>
|
||||||
<Link href={href} className={moduleItemClass(active)}>
|
<Link href={entry.href} className={moduleItemClass(active)}>
|
||||||
{active && <SelectionPill />}
|
{active && <SelectionPill />}
|
||||||
<ModuleTile slug={mod.slug} size={20} tone={active ? 'accent' : 'neutral'} />
|
<ModuleTile slug={entry.tileSlug} size={20} tone={active ? 'accent' : 'neutral'} />
|
||||||
<span className="truncate">{mod.name}</span>
|
<span className="truncate">{entry.name}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
|
||||||
|
|
||||||
|
vi.mock('next-intl', () => ({
|
||||||
|
useTranslations: () => (key: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
openInNewTab: 'In neuem Tab öffnen',
|
||||||
|
embedHint: 'Manche Seiten lassen sich nicht einbetten.',
|
||||||
|
notFound: 'Dieses Modul gibt es nicht mehr.',
|
||||||
|
invalidUrl: 'Ungültige Adresse',
|
||||||
|
loading: 'Wird geladen',
|
||||||
|
};
|
||||||
|
return map[key] ?? key;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockGetCustomModule } = vi.hoisted(() => ({ mockGetCustomModule: vi.fn() }));
|
||||||
|
vi.mock('@/lib/custom-modules-api', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/lib/custom-modules-api')>(
|
||||||
|
'@/lib/custom-modules-api',
|
||||||
|
);
|
||||||
|
return { ...actual, getCustomModule: mockGetCustomModule };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { CustomModuleView } from './custom-module-view';
|
||||||
|
|
||||||
|
function mod(url: string) {
|
||||||
|
return {
|
||||||
|
id: 'abc',
|
||||||
|
name: 'Wiki',
|
||||||
|
url,
|
||||||
|
category: 'infrastructure',
|
||||||
|
createdAt: '2026-09-29T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-09-29T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGetCustomModule.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CustomModuleView', () => {
|
||||||
|
it('rendert den Rahmen mit Sandbox, ohne Referrer und ohne allow', async () => {
|
||||||
|
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
|
||||||
|
render(<CustomModuleView id="abc" />);
|
||||||
|
|
||||||
|
const frame = await screen.findByTitle('Wiki');
|
||||||
|
expect(frame.tagName).toBe('IFRAME');
|
||||||
|
expect(frame).toHaveAttribute('src', 'https://example.com/wiki');
|
||||||
|
expect(frame.getAttribute('sandbox')).toBe(XFRAME_SANDBOX);
|
||||||
|
expect(frame.getAttribute('sandbox')).not.toContain('top-navigation');
|
||||||
|
expect(frame).toHaveAttribute('referrerpolicy', 'no-referrer');
|
||||||
|
expect(frame.getAttribute('allow')).toBe('');
|
||||||
|
expect(mockGetCustomModule).toHaveBeenCalledWith('abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt Namen und immer sichtbar den Link „In neuem Tab öffnen“', async () => {
|
||||||
|
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
|
||||||
|
render(<CustomModuleView id="abc" />);
|
||||||
|
|
||||||
|
const link = await screen.findByRole('link', { name: 'In neuem Tab öffnen' });
|
||||||
|
expect(link).toHaveAttribute('href', 'https://example.com/wiki');
|
||||||
|
expect(link).toHaveAttribute('target', '_blank');
|
||||||
|
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||||
|
expect(screen.getByRole('heading', { name: 'Wiki' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'http://example.com',
|
||||||
|
'javascript:alert(1)',
|
||||||
|
'data:text/html,x',
|
||||||
|
'https://user:pw@example.com',
|
||||||
|
])('rendert bei der Adresse %s weder Rahmen noch Link', async (url) => {
|
||||||
|
mockGetCustomModule.mockResolvedValue(mod(url));
|
||||||
|
const { container } = render(<CustomModuleView id="abc" />);
|
||||||
|
|
||||||
|
await screen.findByText('Ungültige Adresse');
|
||||||
|
expect(container.querySelector('iframe')).toBeNull();
|
||||||
|
expect(container.querySelector('a')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt bei 404 den Nicht-gefunden-Text', async () => {
|
||||||
|
mockGetCustomModule.mockResolvedValue(null);
|
||||||
|
const { container } = render(<CustomModuleView id="weg" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Dieses Modul gibt es nicht mehr.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(container.querySelector('iframe')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
|
||||||
|
import { type CustomModule, checkCustomModuleUrl, getCustomModule } from '@/lib/custom-modules-api';
|
||||||
|
|
||||||
|
type LoadState = { status: 'loading' } | { status: 'missing' } | { status: 'ready'; mod: CustomModule };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rahmen-Ansicht eines eigenen Moduls (quick-260929-9wc, D-06): schmale
|
||||||
|
* Leiste mit Name, Hinweis und dem immer sichtbaren Knopf „In neuem Tab
|
||||||
|
* oeffnen“, darunter der flaechenfuellende Rahmen. Sandbox-Wert und
|
||||||
|
* Attribute wie im XFrame-Widget (`XFRAME_SANDBOX`, kein `allow`, kein
|
||||||
|
* Referrer). Iframe und Link entstehen NUR bei einer https-Adresse ohne
|
||||||
|
* Zugangsdaten — auch eine von Hand veraenderte Datenbankzeile mit
|
||||||
|
* `javascript:` oder `http:` wird nie gerendert (T-9WC-03).
|
||||||
|
*/
|
||||||
|
export function CustomModuleView({ id }: { id: string }) {
|
||||||
|
const t = useTranslations('customModules');
|
||||||
|
const [state, setState] = useState<LoadState>({ status: 'loading' });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setState({ status: 'loading' });
|
||||||
|
getCustomModule(id)
|
||||||
|
.then((mod) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setState(mod ? { status: 'ready', mod } : { status: 'missing' });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setState({ status: 'missing' });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (state.status === 'loading') {
|
||||||
|
return <div className="p-4 text-sm text-muted-foreground">{t('loading')}</div>;
|
||||||
|
}
|
||||||
|
if (state.status === 'missing') {
|
||||||
|
return <div className="p-4 text-sm text-muted-foreground">{t('notFound')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { mod } = state;
|
||||||
|
const urlOk = checkCustomModuleUrl(mod.url) === 'ok';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-var(--header-height)-1.5rem)] min-h-[320px] flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="truncate text-base font-semibold text-foreground">{mod.name}</h1>
|
||||||
|
{urlOk && <p className="truncate text-xs text-muted-foreground">{t('embedHint')}</p>}
|
||||||
|
</div>
|
||||||
|
{urlOk && (
|
||||||
|
<a
|
||||||
|
href={mod.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="btn btn-secondary shrink-0"
|
||||||
|
>
|
||||||
|
{t('openInNewTab')}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{urlOk ? (
|
||||||
|
<iframe
|
||||||
|
src={mod.url}
|
||||||
|
title={mod.name}
|
||||||
|
sandbox={XFRAME_SANDBOX}
|
||||||
|
allow=""
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
className="w-full flex-1 rounded-lg border-0 bg-background"
|
||||||
|
data-testid="custom-module-frame"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border border-border p-4 text-sm text-muted-foreground">
|
||||||
|
{t('invalidUrl')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
CustomModuleRequestError,
|
||||||
|
checkCustomModuleUrl,
|
||||||
|
createCustomModule,
|
||||||
|
deleteCustomModule,
|
||||||
|
getCustomModule,
|
||||||
|
listCustomModules,
|
||||||
|
updateCustomModule,
|
||||||
|
} from './custom-modules-api';
|
||||||
|
|
||||||
|
const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() }));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch.mockReset();
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkCustomModuleUrl', () => {
|
||||||
|
it('nimmt eine https-Adresse an', () => {
|
||||||
|
expect(checkCustomModuleUrl('https://a.de')).toBe('ok');
|
||||||
|
});
|
||||||
|
it('lehnt http und Unparsbares als notHttps ab', () => {
|
||||||
|
expect(checkCustomModuleUrl('http://a.de')).toBe('notHttps');
|
||||||
|
expect(checkCustomModuleUrl('kaputt')).toBe('notHttps');
|
||||||
|
expect(checkCustomModuleUrl('javascript:alert(1)')).toBe('notHttps');
|
||||||
|
expect(checkCustomModuleUrl('')).toBe('notHttps');
|
||||||
|
});
|
||||||
|
it('erkennt Zugangsdaten in der Adresse', () => {
|
||||||
|
expect(checkCustomModuleUrl('https://u:p@a.de')).toBe('credentials');
|
||||||
|
expect(checkCustomModuleUrl('https://u@a.de')).toBe('credentials');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('custom-modules-api', () => {
|
||||||
|
it('listCustomModules ruft GET /custom-modules mit credentials include', async () => {
|
||||||
|
mockFetch.mockResolvedValue(new Response(JSON.stringify([{ id: 'a' }]), { status: 200 }));
|
||||||
|
const list = await listCustomModules();
|
||||||
|
expect(list).toEqual([{ id: 'a' }]);
|
||||||
|
const [url, init] = mockFetch.mock.calls[0];
|
||||||
|
expect(String(url)).toMatch(/\/custom-modules$/);
|
||||||
|
expect(init.credentials).toBe('include');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCustomModule liefert null bei 404', async () => {
|
||||||
|
mockFetch.mockResolvedValue(new Response('{}', { status: 404 }));
|
||||||
|
await expect(getCustomModule('x')).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCustomModule liefert die Zeile bei 200', async () => {
|
||||||
|
mockFetch.mockResolvedValue(new Response(JSON.stringify({ id: 'x' }), { status: 200 }));
|
||||||
|
await expect(getCustomModule('x')).resolves.toEqual({ id: 'x' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createCustomModule schickt POST mit JSON und wirft bei Fehler mit Servermeldung', async () => {
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ message: ['Nur https'] }), { status: 400 }),
|
||||||
|
);
|
||||||
|
const input = { name: 'a', url: 'http://a.de', category: 'fleet' };
|
||||||
|
const err = await createCustomModule(input).catch((e) => e);
|
||||||
|
expect(err).toBeInstanceOf(CustomModuleRequestError);
|
||||||
|
expect(err.status).toBe(400);
|
||||||
|
expect(err.message).toBe('Nur https');
|
||||||
|
const [, init] = mockFetch.mock.calls[0];
|
||||||
|
expect(init.method).toBe('POST');
|
||||||
|
expect(JSON.parse(init.body)).toEqual(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateCustomModule schickt PATCH, deleteCustomModule DELETE', async () => {
|
||||||
|
mockFetch.mockResolvedValue(new Response('{}', { status: 200 }));
|
||||||
|
await updateCustomModule('x', { name: 'n' });
|
||||||
|
expect(mockFetch.mock.calls[0][1].method).toBe('PATCH');
|
||||||
|
expect(String(mockFetch.mock.calls[0][0])).toMatch(/\/custom-modules\/x$/);
|
||||||
|
await deleteCustomModule('x');
|
||||||
|
expect(mockFetch.mock.calls[1][1].method).toBe('DELETE');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Eigene Module — API-Client (quick-260929-9wc). Konsumiert `/custom-modules`.
|
||||||
|
* Muster `favorites-api.ts`/`proxmox-api.ts`: `credentials: 'include'` fuer
|
||||||
|
* Cookie-Auth, `NEXT_PUBLIC_API_URL` als Basis.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isHttpsUrl } from '@/components/dashboard/widgets/xframe-config';
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
export interface CustomModule {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
category: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomModuleInput {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fehler mit HTTP-Status und Servermeldung (falls vorhanden). */
|
||||||
|
export class CustomModuleRequestError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'CustomModuleRequestError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ergebnis der Adresspruefung im Web — dieselbe Regel wie die API. */
|
||||||
|
export type CustomModuleUrlCheck = 'ok' | 'notHttps' | 'credentials';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gueltig ist nur eine https-Adresse ohne Zugangsdaten. Die https-Regel ist
|
||||||
|
* EINE im ganzen Web (`isHttpsUrl` aus `xframe-config.ts`); Zugangsdaten
|
||||||
|
* erkennt der URL-Parser an `username`/`password`.
|
||||||
|
*/
|
||||||
|
export function checkCustomModuleUrl(value: string): CustomModuleUrlCheck {
|
||||||
|
if (!isHttpsUrl(value)) return 'notHttps';
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
if (parsed.username !== '' || parsed.password !== '') return 'credentials';
|
||||||
|
if (parsed.hostname === '') return 'notHttps';
|
||||||
|
} catch {
|
||||||
|
return 'notHttps';
|
||||||
|
}
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function failure(res: Response): Promise<CustomModuleRequestError> {
|
||||||
|
let message = `Request failed (${res.status})`;
|
||||||
|
try {
|
||||||
|
const body = await res.json();
|
||||||
|
const raw = body?.message;
|
||||||
|
if (Array.isArray(raw)) message = raw.join(' ');
|
||||||
|
else if (typeof raw === 'string') message = raw;
|
||||||
|
} catch {
|
||||||
|
// Antwort ohne JSON-Koerper — Standardmeldung bleibt.
|
||||||
|
}
|
||||||
|
return new CustomModuleRequestError(res.status, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCustomModules(): Promise<CustomModule[]> {
|
||||||
|
const res = await fetch(`${API_URL}/custom-modules`, { credentials: 'include' });
|
||||||
|
if (!res.ok) throw await failure(res);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `null` bei 404 (Eintrag geloescht oder fremd). */
|
||||||
|
export async function getCustomModule(id: string): Promise<CustomModule | null> {
|
||||||
|
const res = await fetch(`${API_URL}/custom-modules/${encodeURIComponent(id)}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (res.status === 404) return null;
|
||||||
|
if (!res.ok) throw await failure(res);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCustomModule(input: CustomModuleInput): Promise<CustomModule> {
|
||||||
|
const res = await fetch(`${API_URL}/custom-modules`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw await failure(res);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCustomModule(
|
||||||
|
id: string,
|
||||||
|
input: Partial<CustomModuleInput>,
|
||||||
|
): Promise<CustomModule> {
|
||||||
|
const res = await fetch(`${API_URL}/custom-modules/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw await failure(res);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCustomModule(id: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API_URL}/custom-modules/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw await failure(res);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolvePageTitle } from './nav-store';
|
||||||
|
|
||||||
|
describe('resolvePageTitle — eigene Module', () => {
|
||||||
|
it('liefert den Namen des eigenen Moduls (slug = id)', () => {
|
||||||
|
const modules = [{ id: 'abc', slug: 'abc', name: 'Wiki', category: 'infrastructure' }];
|
||||||
|
expect(resolvePageTitle('/modules/custom/abc', modules)).toEqual({ text: 'Wiki' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findet eingebaute Module weiterhin ueber ihren slug', () => {
|
||||||
|
const modules = [
|
||||||
|
{ id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'domain-tools' },
|
||||||
|
{ id: 'abc', slug: 'abc', name: 'Wiki', category: 'infrastructure' },
|
||||||
|
];
|
||||||
|
expect(resolvePageTitle('/modules/domain-tools/domaincheck', modules)).toEqual({
|
||||||
|
text: 'Domaincheck',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1346,6 +1346,13 @@
|
|||||||
"close": "Fenster schließen",
|
"close": "Fenster schließen",
|
||||||
"contentLabel": "Änderungen"
|
"contentLabel": "Änderungen"
|
||||||
},
|
},
|
||||||
|
"customModules": {
|
||||||
|
"openInNewTab": "In neuem Tab öffnen",
|
||||||
|
"embedHint": "Manche Seiten lassen sich nicht einbetten. Öffnen Sie die Seite dann in einem neuen Tab.",
|
||||||
|
"notFound": "Dieses Modul gibt es nicht mehr.",
|
||||||
|
"invalidUrl": "Die Adresse dieses Moduls ist keine gültige https-Adresse und wird deshalb nicht angezeigt.",
|
||||||
|
"loading": "Wird geladen …"
|
||||||
|
},
|
||||||
"moduleCategories": {
|
"moduleCategories": {
|
||||||
"domain-tools": "Domains",
|
"domain-tools": "Domains",
|
||||||
"security-tools": "Sicherheit",
|
"security-tools": "Sicherheit",
|
||||||
|
|||||||
@@ -1346,6 +1346,13 @@
|
|||||||
"close": "Close window",
|
"close": "Close window",
|
||||||
"contentLabel": "Changes"
|
"contentLabel": "Changes"
|
||||||
},
|
},
|
||||||
|
"customModules": {
|
||||||
|
"openInNewTab": "Open in new tab",
|
||||||
|
"embedHint": "Some pages cannot be embedded. If this one stays blank, open it in a new tab.",
|
||||||
|
"notFound": "This module no longer exists.",
|
||||||
|
"invalidUrl": "This module's address is not a valid https address and is therefore not shown.",
|
||||||
|
"loading": "Loading …"
|
||||||
|
},
|
||||||
"moduleCategories": {
|
"moduleCategories": {
|
||||||
"domain-tools": "Domains",
|
"domain-tools": "Domains",
|
||||||
"security-tools": "Security",
|
"security-tools": "Security",
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { MODULE_CATEGORIES } from '@tessera/shared';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import de from './de.json';
|
||||||
|
import en from './en.json';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gleichlauf der Modulkategorien (quick-260929-9wc): jede Kennung aus
|
||||||
|
* `MODULE_CATEGORIES` (Auswahl im Formular „Eigene Module“, Pruefung in der
|
||||||
|
* API) braucht einen Anzeigenamen in `moduleCategories` beider Sprachen —
|
||||||
|
* sonst zeigte die Seitenleiste die rohe Kennung.
|
||||||
|
*/
|
||||||
|
describe('MODULE_CATEGORIES', () => {
|
||||||
|
it.each([
|
||||||
|
['de', de],
|
||||||
|
['en', en],
|
||||||
|
] as const)('hat je Kennung einen Schluessel in moduleCategories (%s)', (_lang, messages) => {
|
||||||
|
const labels = messages.moduleCategories as Record<string, string>;
|
||||||
|
for (const category of MODULE_CATEGORIES) {
|
||||||
|
expect(labels[category], `moduleCategories.${category}`).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -166,7 +166,7 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet
|
|||||||
| groups | 0 | 31 | 0 | **war 37/0** — Aufgabe 2/3 (260909-jts) haben `groups.service.ts` (12 Methoden) und `module-grants.service.ts` (5 Methoden) vollständig auf `forTenant()`/`withTenantTransaction()` umgestellt. Die neun zusätzlichen, über `tx` gebundenen Zugriffe innerhalb der drei Transaktionen zählt dieses einfache Muster nicht mit (siehe Methodenhinweis oben) |
|
| groups | 0 | 31 | 0 | **war 37/0** — Aufgabe 2/3 (260909-jts) haben `groups.service.ts` (12 Methoden) und `module-grants.service.ts` (5 Methoden) vollständig auf `forTenant()`/`withTenantTransaction()` umgestellt. Die neun zusätzlichen, über `tx` gebundenen Zugriffe innerhalb der drei Transaktionen zählt dieses einfache Muster nicht mit (siehe Methodenhinweis oben) |
|
||||||
| ldap | 1 | 27 | 2 | **war 21/0** — Aufgabe 2/3 (260909-ipc) haben `ldap-config.service.ts` (5 Methoden) und `ldap.service.ts` (6 Methoden, 11 Abfragen) auf `forTenant()` umgestellt. Die 4 verbleibenden ungebundenen Treffer waren bewusst: `getAllActiveConfigs`/`onApplicationBootstrap` (Befund B) und `resolveEmailForWrite` (Befund A, T-IPC-04). **260914-eym:** die beiden Leser in `ldap-config.service.ts` laufen über `forSystem()` (4→1 ungebunden, 2 System), die Schreibzeile der Nachverschlüsselung über `forTenant()` (26→27 gebunden); der eine verbleibende ungebundene Rohtreffer ist `resolveEmailForWrite` |
|
| ldap | 1 | 27 | 2 | **war 21/0** — Aufgabe 2/3 (260909-ipc) haben `ldap-config.service.ts` (5 Methoden) und `ldap.service.ts` (6 Methoden, 11 Abfragen) auf `forTenant()` umgestellt. Die 4 verbleibenden ungebundenen Treffer waren bewusst: `getAllActiveConfigs`/`onApplicationBootstrap` (Befund B) und `resolveEmailForWrite` (Befund A, T-IPC-04). **260914-eym:** die beiden Leser in `ldap-config.service.ts` laufen über `forSystem()` (4→1 ungebunden, 2 System), die Schreibzeile der Nachverschlüsselung über `forTenant()` (26→27 gebunden); der eine verbleibende ungebundene Rohtreffer ist `resolveEmailForWrite` |
|
||||||
| dkv | 0 | 22 | 1 | **war 21/0** — Aufgabe 2/3 (260909-mir) haben `dkv.service.ts` vollständig auf `forTenant()` umgestellt: Konfigurationspfade (`loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection`), Historie, Fahrzeugstammdaten und der neue Besitzriegel vor dem Ausfuhrdatei-Download. Gebunden sind es 22 statt 21, weil der Riegel einen zusätzlichen Lesezugriff auf `dkvInvoiceHistory` einführt (T-MIR-03). Der eine verbleibende ungebundene Treffer war der benannte Planer-Startpfad `loadAnyActiveConfigForScheduler()` (Befund D, WINDOWS #21). **260914-eym:** ersetzt durch `loadActiveConfigsForScheduler()` über `forSystem()` (1→0 ungebunden, 1 System) — WINDOWS #21 geschlossen |
|
| dkv | 0 | 22 | 1 | **war 21/0** — Aufgabe 2/3 (260909-mir) haben `dkv.service.ts` vollständig auf `forTenant()` umgestellt: Konfigurationspfade (`loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection`), Historie, Fahrzeugstammdaten und der neue Besitzriegel vor dem Ausfuhrdatei-Download. Gebunden sind es 22 statt 21, weil der Riegel einen zusätzlichen Lesezugriff auf `dkvInvoiceHistory` einführt (T-MIR-03). Der eine verbleibende ungebundene Treffer war der benannte Planer-Startpfad `loadAnyActiveConfigForScheduler()` (Befund D, WINDOWS #21). **260914-eym:** ersetzt durch `loadActiveConfigsForScheduler()` über `forSystem()` (1→0 ungebunden, 1 System) — WINDOWS #21 geschlossen |
|
||||||
| user | 8 | 17 | 0 | **quick-260925-bow:** +3 gebunden in `user.controller.ts`, „Was ist neu“-Fenster, `GET me/release-notice` (ein `findUnique`) und `POST me/release-seen` (`findUnique` + `update`), beide über `forTenant()` mit `where: { id: currentUser.id }`, nachgemessen mit der Gate-Schleife: 8/17/0. Vorher: **war 17/0** — Aufgabe 2/3 (260910-das) haben `user.service.ts` (`findById`/`create`/`update`/`deactivate`/`delete` sowie die zwei neuen Plattform-Administratorsicht-Methoden), `admin-seed.service.ts` (Erstanlage des Administrators) und `user.controller.ts` (Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege ueber die Dienstmethoden, alle fuenf Selbstbedienungszugriffe) auf `forTenant()` umgestellt. Die 8 verbleibenden ungebundenen Rohtreffer sind bewusst: `findByUsername` in `user.service.ts` (plattformweit eindeutiger Schluessel, derselbe Fall wie `resolveEmailForWrite` im Bereich `ldap`), die Erstanlage-Pruefung und beide Zugriffe auf `tenant` in `admin-seed.service.ts`, sowie der neue Schleifentreiber `this.prisma.tenant.findMany` der beiden Plattform-Administratorsicht-Methoden in `user.service.ts` (`Tenant` traegt keinen Zeilenschutz) |
|
| user | 8 | 18 | 0 | **Nachgemessen quick-260929-9wc:** 8/18/0 — die Zeile nannte 17 gebunden, gemessen sind 18 (Drift aus quick-260928-ujj, Hintergrund pro Benutzer, nachgeholt). Vorher: **quick-260925-bow:** +3 gebunden in `user.controller.ts`, „Was ist neu“-Fenster, `GET me/release-notice` (ein `findUnique`) und `POST me/release-seen` (`findUnique` + `update`), beide über `forTenant()` mit `where: { id: currentUser.id }`, nachgemessen mit der Gate-Schleife: 8/17/0. Vorher: **war 17/0** — Aufgabe 2/3 (260910-das) haben `user.service.ts` (`findById`/`create`/`update`/`deactivate`/`delete` sowie die zwei neuen Plattform-Administratorsicht-Methoden), `admin-seed.service.ts` (Erstanlage des Administrators) und `user.controller.ts` (Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege ueber die Dienstmethoden, alle fuenf Selbstbedienungszugriffe) auf `forTenant()` umgestellt. Die 8 verbleibenden ungebundenen Rohtreffer sind bewusst: `findByUsername` in `user.service.ts` (plattformweit eindeutiger Schluessel, derselbe Fall wie `resolveEmailForWrite` im Bereich `ldap`), die Erstanlage-Pruefung und beide Zugriffe auf `tenant` in `admin-seed.service.ts`, sowie der neue Schleifentreiber `this.prisma.tenant.findMany` der beiden Plattform-Administratorsicht-Methoden in `user.service.ts` (`Tenant` traegt keinen Zeilenschutz) |
|
||||||
| module-registry | 7 | 10 | 0 | **war 17/0** — Aufgabe 2/3 (260910-exd) haben `module-access.service.ts` (`getAccessibleModuleIds`: Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge; `getCatalogFlags`: eigener Aktivierungs-Lesezugriff) und `module-registry.service.ts` (`findActiveForTenant`, `activateForTenant`, `deactivateForTenant`, `isModuleActive`) auf `forTenant()` umgestellt. Die 7 verbleibenden ungebundenen Rohtreffer sind bewusst: der eine Katalogzugriff in `module-access.service.ts` (`findAccessibleModules`) und die sechs Katalogzugriffe in `module-registry.service.ts` (`findAll`, `findBySlug`, die beiden Katalog-Existenzpruefungen in `activateForTenant`/`deactivateForTenant`, die Katalogsuche in `isModuleActive`, `seedModule`) — der Modulkatalog (`Module`) traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal; katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E) |
|
| module-registry | 7 | 10 | 0 | **war 17/0** — Aufgabe 2/3 (260910-exd) haben `module-access.service.ts` (`getAccessibleModuleIds`: Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge; `getCatalogFlags`: eigener Aktivierungs-Lesezugriff) und `module-registry.service.ts` (`findActiveForTenant`, `activateForTenant`, `deactivateForTenant`, `isModuleActive`) auf `forTenant()` umgestellt. Die 7 verbleibenden ungebundenen Rohtreffer sind bewusst: der eine Katalogzugriff in `module-access.service.ts` (`findAccessibleModules`) und die sechs Katalogzugriffe in `module-registry.service.ts` (`findAll`, `findBySlug`, die beiden Katalog-Existenzpruefungen in `activateForTenant`/`deactivateForTenant`, die Katalogsuche in `isModuleActive`, `seedModule`) — der Modulkatalog (`Module`) traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal; katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E) |
|
||||||
| dashboard | 1 | 29 | 0 | **quick-260924-m4n (Stufe 2 der Bilderrahmen-Umstellung):** nachgemessen mit der Gate-Schleife 1/29/0 — die Zeile nannte zuletzt 1/28/1, gemessen waren vor dieser Änderung aber schon 1/31/1: quick-260923-lrr hatte in `dashboard.service.ts` zwei gebundene `tenantPrisma.favoriteLink.`-Rohtreffer (Aufräumen hochgeladener Favoriten-Symbole) hinzugefügt, ohne diese Zeile nachzuziehen, und die ad9-Zählung lag um eins zu niedrig. Diese Änderung selbst: −2 gebunden und −1 System in `dashboard-images.service.ts` — der Bootstrap-Umzug ist entfernt (sein `systemPrisma.dashboardImage.findMany` und sein je Zeile gebundenes `update`), und der Upload legt die Zeile gleich MIT `storagePath` an (UUID vom Dienst), das nachträgliche `update` entfällt. Übrig in `dashboard-images.service.ts`: 7 gebundene Rohtreffer (`findMany`, `count`, `create`, `delete` beim Zurücknehmen, zweimal `findUnique`, `delete`). Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** 24→28 gebunden — Task 2 (Reiter anlegen/umbenennen/löschen/umsortieren) bringt vier weitere gebundene `tenantPrisma.dashboard.`-Rohtreffer in `dashboard.service.ts`: `createDashboard` (`findMany` der vorhandenen Namen, `create`), `renameDashboard` (`update`), `deleteDashboard` (die Zählung vor dem Löschen). Die Schreib-/Lese-Zugriffe INNERHALB der `withTenantTransaction` in `deleteDashboard`/`reorderDashboards` (`tx.dashboard.*`, `tx.widgetInstance.deleteMany`, `tx.dashboardLayout.deleteMany`) zählt diese einfache Rohtrefferzählung strukturell NICHT mit — dieselbe dokumentierte Lücke wie bei `groups.service.ts` (siehe Kopf dieses Abschnitts); sie sind trotzdem gebunden (jeder Aufruf von `withTenantTransaction(` zählt als gebunden) und stehen deshalb bereits als `gebunden` in den Paaren `dashboard`/`widgetInstance`/`dashboardLayout` unten. Nachgemessen mit der Gate-Schleife. Vorher: **quick-260923-ad9 (Task 1):** 21→24 gebunden — die neue Reitertabelle bringt drei gebundene `dashboard`-Rohtreffer in `dashboard.service.ts` (zwei `findMany` in `listDashboards`, ein `findUnique` im Riegel `assertOwnedDashboard`), nachgemessen mit der Gate-Schleife. Vorher: **260922-hk4:** 18→21 gebunden, 0→1 System — die Bilderrahmen-Bilder liegen jetzt im Dateibereich statt in der Spalte `data`. Drei zusätzliche gebundene Rohtreffer in `dashboard-images.service.ts`: das Nachtragen von `storagePath` nach dem Upload (die UUID steht erst nach `create` fest), das Zurücknehmen der Zeile bei fehlgeschlagenem Schreiben, und das Nachtragen im Umzug beim Start. Der eine System-Rohtreffer ist die Lesehälfte dieses Umzugs (`onApplicationBootstrap`, Zeilen ohne `storagePath` über ALLE Mandanten, Muster DKV-Planer) — geschrieben wird auch dort je Zeile mandantengebunden. Nachgemessen mit der Gate-Schleife. Vorher: **260921-pi9:** 12→18 gebunden — `dashboard-images.service.ts` (Bilderrahmen) bringt sechs gebundene `dashboardImage`-Rohtreffer (`findMany`, `count`, `create`, zweimal `findUnique`, `delete`), nachgemessen mit der Gate-Schleife. Vorher: **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) |
|
| dashboard | 1 | 29 | 0 | **quick-260924-m4n (Stufe 2 der Bilderrahmen-Umstellung):** nachgemessen mit der Gate-Schleife 1/29/0 — die Zeile nannte zuletzt 1/28/1, gemessen waren vor dieser Änderung aber schon 1/31/1: quick-260923-lrr hatte in `dashboard.service.ts` zwei gebundene `tenantPrisma.favoriteLink.`-Rohtreffer (Aufräumen hochgeladener Favoriten-Symbole) hinzugefügt, ohne diese Zeile nachzuziehen, und die ad9-Zählung lag um eins zu niedrig. Diese Änderung selbst: −2 gebunden und −1 System in `dashboard-images.service.ts` — der Bootstrap-Umzug ist entfernt (sein `systemPrisma.dashboardImage.findMany` und sein je Zeile gebundenes `update`), und der Upload legt die Zeile gleich MIT `storagePath` an (UUID vom Dienst), das nachträgliche `update` entfällt. Übrig in `dashboard-images.service.ts`: 7 gebundene Rohtreffer (`findMany`, `count`, `create`, `delete` beim Zurücknehmen, zweimal `findUnique`, `delete`). Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** 24→28 gebunden — Task 2 (Reiter anlegen/umbenennen/löschen/umsortieren) bringt vier weitere gebundene `tenantPrisma.dashboard.`-Rohtreffer in `dashboard.service.ts`: `createDashboard` (`findMany` der vorhandenen Namen, `create`), `renameDashboard` (`update`), `deleteDashboard` (die Zählung vor dem Löschen). Die Schreib-/Lese-Zugriffe INNERHALB der `withTenantTransaction` in `deleteDashboard`/`reorderDashboards` (`tx.dashboard.*`, `tx.widgetInstance.deleteMany`, `tx.dashboardLayout.deleteMany`) zählt diese einfache Rohtrefferzählung strukturell NICHT mit — dieselbe dokumentierte Lücke wie bei `groups.service.ts` (siehe Kopf dieses Abschnitts); sie sind trotzdem gebunden (jeder Aufruf von `withTenantTransaction(` zählt als gebunden) und stehen deshalb bereits als `gebunden` in den Paaren `dashboard`/`widgetInstance`/`dashboardLayout` unten. Nachgemessen mit der Gate-Schleife. Vorher: **quick-260923-ad9 (Task 1):** 21→24 gebunden — die neue Reitertabelle bringt drei gebundene `dashboard`-Rohtreffer in `dashboard.service.ts` (zwei `findMany` in `listDashboards`, ein `findUnique` im Riegel `assertOwnedDashboard`), nachgemessen mit der Gate-Schleife. Vorher: **260922-hk4:** 18→21 gebunden, 0→1 System — die Bilderrahmen-Bilder liegen jetzt im Dateibereich statt in der Spalte `data`. Drei zusätzliche gebundene Rohtreffer in `dashboard-images.service.ts`: das Nachtragen von `storagePath` nach dem Upload (die UUID steht erst nach `create` fest), das Zurücknehmen der Zeile bei fehlgeschlagenem Schreiben, und das Nachtragen im Umzug beim Start. Der eine System-Rohtreffer ist die Lesehälfte dieses Umzugs (`onApplicationBootstrap`, Zeilen ohne `storagePath` über ALLE Mandanten, Muster DKV-Planer) — geschrieben wird auch dort je Zeile mandantengebunden. Nachgemessen mit der Gate-Schleife. Vorher: **260921-pi9:** 12→18 gebunden — `dashboard-images.service.ts` (Bilderrahmen) bringt sechs gebundene `dashboardImage`-Rohtreffer (`findMany`, `count`, `create`, zweimal `findUnique`, `delete`), nachgemessen mit der Gate-Schleife. Vorher: **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) |
|
||||||
| auth | 3 | 10 | 0 | **war 8/5** — 260911-fh9 (Aufgabe 2) hat `getMe`, `changePassword`, `adminResetPassword` (fünf Rohtreffer auf `user`, drei Methoden) auf `forTenant()` umgestellt. Die 3 verbleibenden ungebundenen Rohtreffer sind die `$queryRaw`-Aufrufe der drei Anmeldefunktionen (`validateUser`, `requestPasswordReset`, `resetPassword`) — KEINE Modellzugriffe (`$` liegt nicht in `[a-zA-Z]`, die Bestandsaufnahme führt sie deshalb nicht als (Datei, Modell)-Paar), bewusst und dauerhaft ungebunden, siehe `20260909160000_auth_lookup_functions` und `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h1) |
|
| auth | 3 | 10 | 0 | **war 8/5** — 260911-fh9 (Aufgabe 2) hat `getMe`, `changePassword`, `adminResetPassword` (fünf Rohtreffer auf `user`, drei Methoden) auf `forTenant()` umgestellt. Die 3 verbleibenden ungebundenen Rohtreffer sind die `$queryRaw`-Aufrufe der drei Anmeldefunktionen (`validateUser`, `requestPasswordReset`, `resetPassword`) — KEINE Modellzugriffe (`$` liegt nicht in `[a-zA-Z]`, die Bestandsaufnahme führt sie deshalb nicht als (Datei, Modell)-Paar), bewusst und dauerhaft ungebunden, siehe `20260909160000_auth_lookup_functions` und `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h1) |
|
||||||
@@ -176,9 +176,10 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet
|
|||||||
| bug-reports | 0 | 1 | 0 | neu (260914-m97), ein gebundener Zugriff |
|
| bug-reports | 0 | 1 | 0 | neu (260914-m97), ein gebundener Zugriff |
|
||||||
| settings | 0 | 4 | 0 | **Nachgemessen 260921-pi9: 4 gebundene Rohtreffer** (die Tabelle nannte 3; der vierte `smtpConfig`-Zugriff kam mit 260914-m97/`bugReportRecipient` hinzu, ohne dass die Zeile nachgezogen wurde). **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen |
|
| settings | 0 | 4 | 0 | **Nachgemessen 260921-pi9: 4 gebundene Rohtreffer** (die Tabelle nannte 3; der vierte `smtpConfig`-Zugriff kam mit 260914-m97/`bugReportRecipient` hinzu, ohne dass die Zeile nachgezogen wurde). **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen |
|
||||||
| proxmox | 0 | 11 | 1 | **quick-260923-dhh (Aufgabe 5, Endstand):** 7→11 gebunden — `updateServer` (`proxmoxServer.findUnique` UND `.update`) und `deleteServer` (`proxmoxServer.findUnique` UND `.delete`) bringen vier weitere gebundene Rohtreffer, je ein Klient je Methode. Nachgemessen mit der Gate-Schleife (`grep -c` ueber `tenantPrisma\.\(proxmoxServer\|proxmoxServerStatus\)\.` in `proxmox.service.ts`: 10 fuer `proxmoxServer`, 1 fuer `proxmoxServerStatus`). Vorher: **quick-260923-dhh (Aufgabe 4):** 4→7 gebunden, 0→1 System — `proxmox.service.ts` bringt drei weitere gebundene Rohtreffer (`pollServer` mit `include: { status: true }` bleibt EIN Klient, `testConnection`, `listActiveServerIdsForTenant`, `loadActiveServersForTenantScheduling` — vier neue Methoden, aber `pollServer`s zweiter Zugriff war schon gezaehlt, macht drei zusaetzliche) und einen System-Rohtreffer (`loadActiveServersForScheduler()`, der einzige `forSystem()`-Aufruf des Moduls, Erlaubnisliste in `rls-access-inventory.spec.ts`). Vorher: **quick-260923-dhh (Aufgabe 1):** neu, vier gebundene Rohtreffer: `createServer` (`proxmoxServer.create`), `listWithStatus` (`proxmoxServer.findMany`), `pollServer` (`proxmoxServer.findUnique` UND `proxmoxServerStatus.upsert`, DERSELBE Klient in derselben Methode) |
|
| proxmox | 0 | 11 | 1 | **quick-260923-dhh (Aufgabe 5, Endstand):** 7→11 gebunden — `updateServer` (`proxmoxServer.findUnique` UND `.update`) und `deleteServer` (`proxmoxServer.findUnique` UND `.delete`) bringen vier weitere gebundene Rohtreffer, je ein Klient je Methode. Nachgemessen mit der Gate-Schleife (`grep -c` ueber `tenantPrisma\.\(proxmoxServer\|proxmoxServerStatus\)\.` in `proxmox.service.ts`: 10 fuer `proxmoxServer`, 1 fuer `proxmoxServerStatus`). Vorher: **quick-260923-dhh (Aufgabe 4):** 4→7 gebunden, 0→1 System — `proxmox.service.ts` bringt drei weitere gebundene Rohtreffer (`pollServer` mit `include: { status: true }` bleibt EIN Klient, `testConnection`, `listActiveServerIdsForTenant`, `loadActiveServersForTenantScheduling` — vier neue Methoden, aber `pollServer`s zweiter Zugriff war schon gezaehlt, macht drei zusaetzliche) und einen System-Rohtreffer (`loadActiveServersForScheduler()`, der einzige `forSystem()`-Aufruf des Moduls, Erlaubnisliste in `rls-access-inventory.spec.ts`). Vorher: **quick-260923-dhh (Aufgabe 1):** neu, vier gebundene Rohtreffer: `createServer` (`proxmoxServer.create`), `listWithStatus` (`proxmoxServer.findMany`), `pollServer` (`proxmoxServer.findUnique` UND `proxmoxServerStatus.upsert`, DERSELBE Klient in derselben Methode) |
|
||||||
| **Summe** | **61** | **216** | **6** | **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
|
| custom-modules | 0 | 7 | 0 | **quick-260929-9wc:** neu, sieben gebundene Rohtreffer in `custom-modules.service.ts` (`list` 1, `getOne` 1, `create` 1, `update` 2, `remove` 2), nachgemessen mit der Gate-Schleife: 0/7/0. Kein ungebundener Zugriff, kein Systemkontext. |
|
||||||
|
| **Summe** | **61** | **224** | **6** | **Nachgemessen quick-260929-9wc:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/224/6. Gegenueber der bisherigen Zeile (61/216/6): Gebunden +8 = `user` +1 (Drift aus quick-260928-ujj, siehe dortige Zeile; gemessen war schon vorher 61/217/6) und `custom-modules` +7 (neu, siehe dortige Zeile), Ungebunden/System unveraendert. Vorher: **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
|
||||||
|
|
||||||
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 77 Paare)
|
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 79 Paare)
|
||||||
|
|
||||||
Stand 260909-jts (Aufgabe 3): 61 Paare aus dem vorherigen Durchlauf
|
Stand 260909-jts (Aufgabe 3): 61 Paare aus dem vorherigen Durchlauf
|
||||||
(260909-ipc) plus ein bisher vollstaendig unsichtbares Paar
|
(260909-ipc) plus ein bisher vollstaendig unsichtbares Paar
|
||||||
@@ -343,16 +344,24 @@ entnommen (30 Zusicherungen, darunter der Wachhund
|
|||||||
|
|
||||||
| Klasse | Anzahl Paare |
|
| Klasse | Anzahl Paare |
|
||||||
|---|---|
|
|---|---|
|
||||||
| muss-mandantengebunden | 40 |
|
| muss-mandantengebunden | 42 |
|
||||||
| keine-mandantengebundene-tabelle | 21 |
|
| keine-mandantengebundene-tabelle | 21 |
|
||||||
| beides | 14 |
|
| beides | 14 |
|
||||||
| bewusst-uebergreifend | 2 |
|
| bewusst-uebergreifend | 2 |
|
||||||
| **Summe** | **77** |
|
| **Summe** | **79** |
|
||||||
|
|
||||||
quick-260923-dhh (Aufgabe 1): +2 `muss-mandantengebunden` (`proxmox.service.ts`/`proxmoxServer`
|
quick-260923-dhh (Aufgabe 1): +2 `muss-mandantengebunden` (`proxmox.service.ts`/`proxmoxServer`
|
||||||
und `/proxmoxServerStatus`, beide `gebunden`) — nachgerechnet mit der Gate-Schleife, nicht
|
und `/proxmoxServerStatus`, beide `gebunden`) — nachgerechnet mit der Gate-Schleife, nicht
|
||||||
abgeschrieben.
|
abgeschrieben.
|
||||||
|
|
||||||
|
quick-260929-9wc: nachgezaehlt mit `grep -cE '^\| apps/api/src/'` gegen die Bestandsaufnahme:
|
||||||
|
Vorher standen 78 Zeilen (41 `muss-mandantengebunden`) im Dokument, Ueberschrift und Tabelle
|
||||||
|
nannten aber noch 77 Paare/40 `muss-mandantengebunden` — Drift, eine Zeile war nach der
|
||||||
|
Tabelle hinzugekommen, ohne sie fortzuschreiben. Jetzt +1 `muss-mandantengebunden`
|
||||||
|
(`custom-modules.service.ts`/`customModule`, `gebunden`): 79 Paare, davon 42
|
||||||
|
`muss-mandantengebunden`, 21 `keine-mandantengebundene-tabelle`, 14 `beides`,
|
||||||
|
2 `bewusst-uebergreifend`.
|
||||||
|
|
||||||
## Der Hintergrunddienst als Falle — sechs Fälle
|
## Der Hintergrunddienst als Falle — sechs Fälle
|
||||||
|
|
||||||
Ein Planer, der über alle Mandanten iteriert, liest zu Recht übergreifend —
|
Ein Planer, der über alle Mandanten iteriert, liest zu Recht übergreifend —
|
||||||
@@ -765,6 +774,7 @@ werden.
|
|||||||
| apps/api/src/user/user.service.ts | user | beides | gemischt | Klassenkorrektur (260910-das, Aufgabe 3): wechselt von `muss-mandantengebunden` auf `beides` wegen der einen bewusst ungebundenen Suche — wortgleich derselbe Praezedenzfall wie `ldap.service.ts`/`user` in 260909-ipc (`resolveEmailForWrite`). `findById`/`create`/`update`/`deactivate`/`delete` sowie die beiden neuen Plattform-Administratorsicht-Methoden laufen ueber `forTenant()`; `create`/`update` uebersetzen eine plattformweite Eindeutigkeitsverletzung (P2002) in eine deutsche Konfliktmeldung ohne Halter/Mandant zu nennen. `findByUsername` bleibt bewusst UNGEBUNDEN: der Anmeldeweg laeuft seit Etappe 1 ueber die drei SECURITY-DEFINER-Funktionen und hat diese Methode nicht mehr als Aufrufer (260910-das, Aufgabe 1, Teil 3: genau ein Treffer, die eigene Definition); eine gebundene Suche saehe einen fremden Halter des plattformweit eindeutigen `username` nicht und meldete faelschlich "frei". |
|
| apps/api/src/user/user.service.ts | user | beides | gemischt | Klassenkorrektur (260910-das, Aufgabe 3): wechselt von `muss-mandantengebunden` auf `beides` wegen der einen bewusst ungebundenen Suche — wortgleich derselbe Praezedenzfall wie `ldap.service.ts`/`user` in 260909-ipc (`resolveEmailForWrite`). `findById`/`create`/`update`/`deactivate`/`delete` sowie die beiden neuen Plattform-Administratorsicht-Methoden laufen ueber `forTenant()`; `create`/`update` uebersetzen eine plattformweite Eindeutigkeitsverletzung (P2002) in eine deutsche Konfliktmeldung ohne Halter/Mandant zu nennen. `findByUsername` bleibt bewusst UNGEBUNDEN: der Anmeldeweg laeuft seit Etappe 1 ueber die drei SECURITY-DEFINER-Funktionen und hat diese Methode nicht mehr als Aufrufer (260910-das, Aufgabe 1, Teil 3: genau ein Treffer, die eigene Definition); eine gebundene Suche saehe einen fremden Halter des plattformweit eindeutigen `username` nicht und meldete faelschlich "frei". |
|
||||||
| apps/api/src/proxmox/proxmox.service.ts | proxmoxServer | muss-mandantengebunden | system-gebunden | **quick-260923-dhh, Aufgabe 4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `loadActiveServersForScheduler()` liest beim Start des Planers `const systemPrisma = forSystem(this.prisma);` (ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht ueber `system_read_policy … FOR SELECT` auf "ProxmoxServer", Migration 20260923140000) — der Planer muss die aktiven Server ALLER Mandanten sehen, um je Mandant einen Cron-Auftrag zu registrieren (Muster `DkvSchedulerService`). GESCHRIEBEN wird auch dort nur je Zeile gebunden. Sechs mandantengebundene Zugriffe blieben nach Aufgabe 4 bestehen: `createServer` (`proxmoxServer.create`), `listWithStatus` (`findMany`), `pollServer` (`findUnique`, mit `include: { status: true }` fuer die Zehn-Sekunden-Sperre), `testConnection` (`findUnique`), `listActiveServerIdsForTenant` (`findMany`), `loadActiveServersForTenantScheduling` (`findMany` auf `proxmoxServer`, `select: { pollIntervalMin: true }`). **Aufgabe 5** ergaenzt vier weitere: `updateServer` (`findUnique` UND `update`) und `deleteServer` (`findUnique` UND `delete`), je ein Klient je Methode — macht zehn mandantengebundene `proxmoxServer`-Rohtreffer insgesamt, plus der eine System-Rohtreffer aus Aufgabe 4. Vorher (Aufgabe 1): vom Administrator eingetragene Proxmox-Server (PVE/PBS/PMG), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, Form aus `DkvModuleConfig`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. `listWithStatus` waehlt die beiden Geheimnisfelder (`encryptedTokenSecret`/`encryptedPassword`) per `select` gar nicht erst aus (T-DHH-01). |
|
| apps/api/src/proxmox/proxmox.service.ts | proxmoxServer | muss-mandantengebunden | system-gebunden | **quick-260923-dhh, Aufgabe 4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `loadActiveServersForScheduler()` liest beim Start des Planers `const systemPrisma = forSystem(this.prisma);` (ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht ueber `system_read_policy … FOR SELECT` auf "ProxmoxServer", Migration 20260923140000) — der Planer muss die aktiven Server ALLER Mandanten sehen, um je Mandant einen Cron-Auftrag zu registrieren (Muster `DkvSchedulerService`). GESCHRIEBEN wird auch dort nur je Zeile gebunden. Sechs mandantengebundene Zugriffe blieben nach Aufgabe 4 bestehen: `createServer` (`proxmoxServer.create`), `listWithStatus` (`findMany`), `pollServer` (`findUnique`, mit `include: { status: true }` fuer die Zehn-Sekunden-Sperre), `testConnection` (`findUnique`), `listActiveServerIdsForTenant` (`findMany`), `loadActiveServersForTenantScheduling` (`findMany` auf `proxmoxServer`, `select: { pollIntervalMin: true }`). **Aufgabe 5** ergaenzt vier weitere: `updateServer` (`findUnique` UND `update`) und `deleteServer` (`findUnique` UND `delete`), je ein Klient je Methode — macht zehn mandantengebundene `proxmoxServer`-Rohtreffer insgesamt, plus der eine System-Rohtreffer aus Aufgabe 4. Vorher (Aufgabe 1): vom Administrator eingetragene Proxmox-Server (PVE/PBS/PMG), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, Form aus `DkvModuleConfig`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. `listWithStatus` waehlt die beiden Geheimnisfelder (`encryptedTokenSecret`/`encryptedPassword`) per `select` gar nicht erst aus (T-DHH-01). |
|
||||||
| apps/api/src/proxmox/proxmox.service.ts | proxmoxServerStatus | muss-mandantengebunden | gebunden | quick-260923-dhh, Aufgabe 1/4 — Zwischenlager je Server (D-05), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, dieselbe Form wie `proxmoxServer`). `pollServer` schreibt ueber `tenantPrisma.proxmoxServerStatus.upsert()`, DENSELBEN Klienten wie das Lesen des Servers in derselben Methode; dieselbe Methode liest zusaetzlich `include: { status: true }` fuer die Zehn-Sekunden-Sperre (Aufgabe 4, T-DHH-06) — ebenfalls ueber den gebundenen Klienten. Bewusst KEINE `system_read_policy` auf dieser Tabelle (anders als `proxmoxServer`) — der Planer-Startpfad liest nur die Serverzeilen, das Zwischenlager wird ausschliesslich je Mandant gebunden geschrieben, ein Systemlesezugriff hat keinen Aufrufer. |
|
| apps/api/src/proxmox/proxmox.service.ts | proxmoxServerStatus | muss-mandantengebunden | gebunden | quick-260923-dhh, Aufgabe 1/4 — Zwischenlager je Server (D-05), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, dieselbe Form wie `proxmoxServer`). `pollServer` schreibt ueber `tenantPrisma.proxmoxServerStatus.upsert()`, DENSELBEN Klienten wie das Lesen des Servers in derselben Methode; dieselbe Methode liest zusaetzlich `include: { status: true }` fuer die Zehn-Sekunden-Sperre (Aufgabe 4, T-DHH-06) — ebenfalls ueber den gebundenen Klienten. Bewusst KEINE `system_read_policy` auf dieser Tabelle (anders als `proxmoxServer`) — der Planer-Startpfad liest nur die Serverzeilen, das Zwischenlager wird ausschliesslich je Mandant gebunden geschrieben, ein Systemlesezugriff hat keinen Aufrufer. |
|
||||||
|
| apps/api/src/custom-modules/custom-modules.service.ts | customModule | muss-mandantengebunden | gebunden | **quick-260929-9wc:** neu — vom Administrator angelegte Seitenleisten-Eintraege („Eigene Module“, Name, https-Adresse, Kategorie), fuer alle Benutzer des Mandanten sichtbar. `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260929120000, Form aus `ProxmoxServer`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. Bewusst KEINE `system_read_policy`: es gibt keinen Hintergrunddienst, der eigene Module ueber alle Mandanten liest. Sieben mandantengebundene Rohtreffer, je Methode ein eigener Klient (`const tenantPrisma = forTenant(this.prisma, tenantId)`): `list` (`findMany` mit `where: { tenantId }`), `getOne` (`findUnique`), `create`, `update` (`findUnique` UND `update`), `remove` (`findUnique` UND `delete`). `getOne`/`update`/`remove` pruefen zusaetzlich `row.tenantId !== tenantId` und antworten mit 404 — zweites Netz, solange der RLS-Schalter aus ist (Muster `dashboardImage`). |
|
||||||
|
|
||||||
## Was diese Etappe NICHT entscheidet
|
## Was diese Etappe NICHT entscheidet
|
||||||
|
|
||||||
|
|||||||
@@ -256,3 +256,21 @@ export function parseDashboardBackground(value: unknown): DashboardBackground |
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die fuenf Seitenleisten-Kategorien der Module (quick-260929-9wc). Die
|
||||||
|
* Liste entspricht den Kategorien in den Seeds der eingebauten Module und
|
||||||
|
* den Schluesseln `moduleCategories` in den Uebersetzungen (de.json/en.json);
|
||||||
|
* `apps/web/src/messages/module-categories.spec.ts` haelt den Gleichlauf.
|
||||||
|
* Die API prueft damit die Kategorie eigener Module, das Formular baut die
|
||||||
|
* Auswahl daraus.
|
||||||
|
*/
|
||||||
|
export const MODULE_CATEGORIES = [
|
||||||
|
"domain-tools",
|
||||||
|
"security-tools",
|
||||||
|
"fleet",
|
||||||
|
"infrastructure",
|
||||||
|
"procurement",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ModuleCategory = (typeof MODULE_CATEGORIES)[number];
|
||||||
|
|||||||
Reference in New Issue
Block a user