Compare commits
4 Commits
v1.8.0
..
31d514b7ca
| Author | SHA1 | Date | |
|---|---|---|---|
| 31d514b7ca | |||
| 52f538c432 | |||
| 32441d77c7 | |||
| 0b34e82b21 |
@@ -4,6 +4,21 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
||||
|
||||
## Unveröffentlicht
|
||||
|
||||
### Neu
|
||||
|
||||
- Benutzerverwaltung: Willkommensmail. Über das Briefsymbol in der Benutzerliste schicken Sie einem Benutzer eine gestaltete Willkommensmail mit Tessera-Logo, Adresse, Benutzername und einem Knopf „Zu Tessera“. Konten aus dem Verzeichnis erhalten den Hinweis auf ihr Windows-Passwort, lokale Konten einen Link „Passwort festlegen“ (7 Tage gültig) – ein Passwort steht nie in der Mail. Die Liste zeigt, wann die Mail zuletzt ging.
|
||||
- Benutzerverwaltung: Neue Spalte „Letzte Anmeldung“.
|
||||
|
||||
### Geändert
|
||||
|
||||
- Benutzerverwaltung: Die Aktionen je Zeile sind jetzt Symbole (Willkommensmail, Details, Bearbeiten, Löschen), damit die Liste ohne seitliches Scrollen passt.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Anmeldung: Wer schon angemeldet ist und die Anmeldeseite aufruft, landet jetzt direkt auf dem Dashboard.
|
||||
- Willkommensmail: Logo und Schriftzug erscheinen jetzt in jedem Mailprogramm. Bisher steckten sie in einem Bild; zeigte Outlook es nicht an, blieb nur ein großer schwarzer Kasten. Die Welle darunter ist nur noch ein schmaler Streifen.
|
||||
- Anmeldung: Eine geänderte Rolle, eine Deaktivierung oder das Löschen eines Kontos wirkt jetzt sofort. Bisher galt bis zu 30 Tage die Rolle vom Zeitpunkt der Anmeldung weiter – ein herabgestufter Administrator behielt seine Rechte, ein deaktiviertes Konto konnte mit seiner Sitzung weiterarbeiten, und die Benutzerliste ließ sich nach einer Rollenänderung nicht laden.
|
||||
|
||||
## 1.8.0 – 2026-09-30
|
||||
|
||||
### Neu
|
||||
|
||||
@@ -47,6 +47,9 @@ COPY --from=builder /app/node_modules/.pnpm/@prisma+client@6.19.3_prisma@6.19.3_
|
||||
COPY --from=builder /app/apps/api/prisma ./apps/api/prisma
|
||||
COPY --from=builder /app/packages/shared/src ./packages/shared/src
|
||||
COPY apps/api/scripts ./apps/api/scripts
|
||||
# Kopfbild der Willkommensmail (MailService.loadWelcomeHeaderPng liest
|
||||
# apps/api/assets/mail/welcome-header.png relativ zu dist/mail/).
|
||||
COPY apps/api/assets ./apps/api/assets
|
||||
# Desktop-Pakete (Phase 18, D-08): im CI legt desktop-collect.sh Pakete +
|
||||
# manifest.json in diesen Ordner, lokal liegt nur der Platzhalter. Nur
|
||||
# lesend zur Laufzeit -- kein chown noetig.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="112" viewBox="0 0 1200 112">
|
||||
<!--
|
||||
Wellenstreifen unter dem Kopf der Tessera-Systemmails (Willkommensmail).
|
||||
Quelle des PNG daneben (welcome-header.png, 1200x112, angezeigt 600x56);
|
||||
erzeugt mit `node apps/api/scripts/render-mail-header.mjs`.
|
||||
|
||||
Seit quick-260930 (Rueckmeldung des Nutzers: in Outlook "ein riesiger
|
||||
schwarzer Fleck, kein Logo") steckt KEIN Logo und KEIN Text mehr im Bild:
|
||||
Bildmarke und Schriftzug stehen als HTML im Mailkopf und erscheinen immer.
|
||||
Dieses Bild ist nur noch Schmuck: oben die Kopffarbe, darunter die
|
||||
Duenen-Wellen des Dashboard-Hintergrunds (dunkle Fassung,
|
||||
apps/web/src/lib/dashboard-background.ts) mit der feinen gelben Linie,
|
||||
unten laeuft es ins Weiss der Karte aus. Zeigt ein Mailprogramm das Bild
|
||||
nicht, bleibt dort nur weisser Abstand.
|
||||
-->
|
||||
<rect width="1200" height="112" fill="#ffffff"/>
|
||||
<!-- Flaechen von unten nach oben uebereinander, jede von oben bis zu ihrer
|
||||
Wellenlinie: so teilen sich benachbarte Baender dieselbe Kante, ohne
|
||||
Luecken dazwischen. -->
|
||||
<path d="M0 0 V92 C220 78 420 102 600 96 S1000 76 1200 86 V0 Z" fill="#d9dce0"/>
|
||||
<path d="M0 0 V70 C250 50 420 86 640 76 S1040 50 1200 64 V0 Z" fill="#2c3036"/>
|
||||
<path d="M0 0 V44 C330 18 520 62 700 52 S1060 22 1200 40 V0 Z" fill="#1a1c20"/>
|
||||
<path d="M0 44 C330 18 520 62 700 52 S1060 22 1200 40" fill="none" stroke="#ffed00" stroke-opacity="0.75" stroke-width="3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,15 @@
|
||||
-- Willkommensmail aus der Benutzerverwaltung (Administrator → Benutzer).
|
||||
--
|
||||
-- Merkt pro Benutzer, wann zuletzt eine Willkommensmail verschickt wurde,
|
||||
-- damit die Liste "Willkommensmail gesendet am …" zeigen kann. Gesetzt nur
|
||||
-- ueber POST /users/:id/welcome-mail nach erfolgreichem Versand; erneutes
|
||||
-- Senden ueberschreibt den Wert. NULL = nie gesendet. Kein Standardwert,
|
||||
-- kein Backfill.
|
||||
--
|
||||
-- Keine neue Regel noetig: die Spalte liegt in "User", dessen
|
||||
-- tenant_isolation_policy die ganze Zeile schuetzt. Die Anmelde-Funktionen
|
||||
-- auth_lookup_* liefern eine feste Spaltenliste (RETURNS TABLE) und bleiben
|
||||
-- unberuehrt.
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "welcomeMailSentAt" TIMESTAMP(3);
|
||||
@@ -50,6 +50,9 @@ model User {
|
||||
// sonst das durch parseDashboardBackground (@tessera/shared) normalisierte
|
||||
// Objekt, auch { kind: 'none' } fuer bewusst "kein Hintergrund"
|
||||
dashboardBackground Json?
|
||||
// Willkommensmail aus der Benutzerverwaltung: Zeitpunkt des letzten
|
||||
// Versands; null = nie gesendet
|
||||
welcomeMailSentAt DateTime?
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
groupMemberships GroupMembership[]
|
||||
moduleGrants ModuleGrant[]
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* render-mail-header.mjs — erzeugt das Kopfbild der Willkommensmail
|
||||
* (apps/api/assets/mail/welcome-header.png, 1200x112 fuer hochaufloesende
|
||||
* Bildschirme, in der Mail 600x56 angezeigt) aus der daneben liegenden
|
||||
* Quelle welcome-header.svg.
|
||||
*
|
||||
* Warum ein PNG statt Inline-SVG oder CSS-Hintergrund: Outlook (Word-
|
||||
* Darstellung) und viele Webmailer zeigen weder SVG noch Hintergrundbilder
|
||||
* zuverlaessig an. Das PNG wird als CID-Anhang eingebettet (MailService),
|
||||
* die Mail laedt also nichts von aussen nach.
|
||||
*
|
||||
* Das PNG liegt fertig im Repo; dieses Skript ist nur noetig, wenn die SVG
|
||||
* geaendert wird. Kein neues Paket: `sharp` ist ueber Next.js (apps/web)
|
||||
* bereits installiert und wird von dort aufgeloest. Der Schriftzug wird
|
||||
* mit den Systemschriften des erzeugenden Rechners gesetzt (fontconfig:
|
||||
* Segoe UI, Inter, Noto Sans, DejaVu Sans — die erste vorhandene gewinnt).
|
||||
*
|
||||
* Aufruf (Repo-Wurzel): node apps/api/scripts/render-mail-header.mjs
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const assetDir = join(here, '..', 'assets', 'mail');
|
||||
const webDir = join(here, '..', '..', 'web');
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const nextPkg = require.resolve('next/package.json', { paths: [webDir] });
|
||||
const sharp = createRequire(nextPkg)('sharp');
|
||||
|
||||
const svg = readFileSync(join(assetDir, 'welcome-header.svg'));
|
||||
const png = await sharp(svg, { density: 72 })
|
||||
.resize(1200, 112)
|
||||
.png({ compressionLevel: 9, palette: false })
|
||||
.toBuffer();
|
||||
writeFileSync(join(assetDir, 'welcome-header.png'), png);
|
||||
console.log(`welcome-header.png geschrieben (${png.length} Bytes)`);
|
||||
@@ -17,6 +17,7 @@ import { LdapService } from '../ldap/ldap.service';
|
||||
import { MailService } from '../mail/mail.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { PASSWORD_RESET_TOKEN_TTL_MS } from './password-reset-token';
|
||||
import type { JwtPayload, LoginUser } from './types/auth-user';
|
||||
|
||||
/**
|
||||
@@ -239,7 +240,7 @@ export class AuthService {
|
||||
|
||||
// Generate a unique reset token
|
||||
const token = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
||||
const expiresAt = new Date(Date.now() + PASSWORD_RESET_TOKEN_TTL_MS); // 1 hour
|
||||
|
||||
// Create the reset token record — mandantengebunden, sobald der
|
||||
// Benutzer und damit sein Mandant bekannt sind (WINDOWS #20, Aufgabe 1).
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { of } from 'rxjs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { JwtStrategy } from '../strategies/jwt.strategy';
|
||||
import { ForcePasswordChangeInterceptor } from './force-password-change.interceptor';
|
||||
|
||||
vi.mock('../../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((p: unknown) => p),
|
||||
}));
|
||||
|
||||
/**
|
||||
* ForcePasswordChangeInterceptor.intercept — pinnt Sperre, Erlaubnisliste
|
||||
* und die Teilstring-Falle (260921-fi3, Aufgabe 1, Befund 1/D-01/D-02/D-03).
|
||||
@@ -30,7 +34,19 @@ const nextHandle = { handle: () => of('ok') } as any;
|
||||
|
||||
describe('ForcePasswordChangeInterceptor.intercept', () => {
|
||||
it('Nahttest (D-03): JwtStrategy.validate() -> request.user -> GET /users wirft ForbiddenException — scheitert gegen den heutigen Quelltext, weil das Feld auf dem Weg verloren geht', async () => {
|
||||
const strategy = new JwtStrategy({ get: () => 'test-secret' } as any);
|
||||
const prisma = {
|
||||
user: {
|
||||
findUnique: async () => ({
|
||||
id: 'u1',
|
||||
username: 'admin',
|
||||
role: 'ADMIN',
|
||||
tenantId: 't1',
|
||||
isActive: true,
|
||||
mustChangePassword: true,
|
||||
}),
|
||||
},
|
||||
} as any;
|
||||
const strategy = new JwtStrategy({ get: () => 'test-secret' } as any, prisma);
|
||||
const user = await strategy.validate({
|
||||
sub: 'u1',
|
||||
username: 'admin',
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Gueltigkeit eines Kennwort-Tokens (`PasswordResetToken`, T-02-13): eine
|
||||
* Stunde, einmal verwendbar. Gemeinsam genutzt vom Weg "Passwort
|
||||
* vergessen" (`AuthService.requestPasswordReset`) und vom Link "Passwort
|
||||
* festlegen" der Willkommensmail (`WelcomeMailService`) — beide legen
|
||||
* dieselbe Art Token an und fuehren auf dieselbe Seite
|
||||
* `/reset-password/<token>`, deshalb gilt dieselbe Frist.
|
||||
*/
|
||||
export const PASSWORD_RESET_TOKEN_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Gueltigkeit des Links "Passwort festlegen" in der Willkommensmail: 7 Tage.
|
||||
* Neue Mitarbeiter lesen die Mail oft erst Tage spaeter; eine Stunde wie bei
|
||||
* "Passwort vergessen" (dort fordert der Benutzer den Link selbst an und
|
||||
* nutzt ihn sofort) waere hier fast immer abgelaufen. Einmal verwendbar
|
||||
* bleibt der Link trotzdem, und jede neue Willkommensmail legt einen neuen an.
|
||||
*/
|
||||
export const WELCOME_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
@@ -1,9 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { forTenant } from '../../prisma/prisma-tenant.extension';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
|
||||
vi.mock('../../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((p: unknown) => p),
|
||||
}));
|
||||
|
||||
/**
|
||||
* JwtStrategy.validate — pinnt die Durchreichung von mustChangePassword
|
||||
* (260921-fi3, Aufgabe 1, Befund 1/D-01). Direkte Konstruktion ohne
|
||||
* JwtStrategy.validate — seit quick-260930 kommen Rolle, Aktiv-Status und
|
||||
* Kennwort-Pflicht bei jeder Anfrage aus der Datenbank, nicht aus dem Token
|
||||
* (Rollenaenderung/Deaktivierung wirkt sofort). Direkte Konstruktion ohne
|
||||
* Nest-Testmodul, Muster aus `../../tenant/tenant.guard.spec.ts`.
|
||||
*/
|
||||
|
||||
@@ -11,51 +18,77 @@ function makeConfigService() {
|
||||
return { get: () => 'test-secret' } as any;
|
||||
}
|
||||
|
||||
describe('JwtStrategy.validate', () => {
|
||||
it('Anspruch mustChangePassword=true im Token: liefert request.user.mustChangePassword === true', async () => {
|
||||
const strategy = new JwtStrategy(makeConfigService());
|
||||
type Row = {
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
tenantId: string;
|
||||
isActive: boolean;
|
||||
mustChangePassword: boolean;
|
||||
} | null;
|
||||
|
||||
const result = await strategy.validate({
|
||||
function makePrisma(row: Row) {
|
||||
return { user: { findUnique: vi.fn(async () => row) } } as any;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
sub: 'u1',
|
||||
username: 'admin',
|
||||
role: 'ADMIN',
|
||||
username: 'kschaller',
|
||||
role: 'SUPER_ADMIN' as const,
|
||||
tenantId: 't1',
|
||||
mustChangePassword: true,
|
||||
});
|
||||
|
||||
expect(result.mustChangePassword).toBe(true);
|
||||
});
|
||||
|
||||
it('Anspruch fehlt im Token (Alt-Sitzung, vor dieser Aenderung ausgestellt): liefert false statt undefined', async () => {
|
||||
const strategy = new JwtStrategy(makeConfigService());
|
||||
|
||||
const result = await strategy.validate({
|
||||
sub: 'u1',
|
||||
username: 'admin',
|
||||
role: 'ADMIN',
|
||||
tenantId: 't1',
|
||||
});
|
||||
|
||||
expect(result.mustChangePassword).toBe(false);
|
||||
});
|
||||
|
||||
it('id, username, role und tenantId werden unveraendert wie bisher durchgereicht', async () => {
|
||||
const strategy = new JwtStrategy(makeConfigService());
|
||||
|
||||
const result = await strategy.validate({
|
||||
sub: 'u1',
|
||||
username: 'nutzer1',
|
||||
role: 'USER',
|
||||
tenantId: 't2',
|
||||
mustChangePassword: false,
|
||||
});
|
||||
};
|
||||
|
||||
const dbRow = {
|
||||
id: 'u1',
|
||||
username: 'kschaller',
|
||||
role: 'ADMIN',
|
||||
tenantId: 't1',
|
||||
isActive: true,
|
||||
mustChangePassword: false,
|
||||
};
|
||||
|
||||
describe('JwtStrategy.validate', () => {
|
||||
it('Rolle kommt aus der Datenbank, nicht aus dem Token (herabgestufter Super-Admin ist sofort Admin)', async () => {
|
||||
const prisma = makePrisma(dbRow);
|
||||
const strategy = new JwtStrategy(makeConfigService(), prisma);
|
||||
|
||||
const result = await strategy.validate(payload);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'u1',
|
||||
username: 'nutzer1',
|
||||
role: 'USER',
|
||||
tenantId: 't2',
|
||||
username: 'kschaller',
|
||||
role: 'ADMIN',
|
||||
tenantId: 't1',
|
||||
mustChangePassword: false,
|
||||
});
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 't1');
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'u1' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('deaktiviertes Konto: 401, auch mit gueltigem Token', async () => {
|
||||
const strategy = new JwtStrategy(makeConfigService(), makePrisma({ ...dbRow, isActive: false }));
|
||||
await expect(strategy.validate(payload)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('geloeschtes Konto: 401', async () => {
|
||||
const strategy = new JwtStrategy(makeConfigService(), makePrisma(null));
|
||||
await expect(strategy.validate(payload)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('Konto gehoert nicht (mehr) zum Mandanten aus dem Token: 401', async () => {
|
||||
const strategy = new JwtStrategy(makeConfigService(), makePrisma({ ...dbRow, tenantId: 't2' }));
|
||||
await expect(strategy.validate(payload)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('Kennwort-Pflicht kommt aus der Datenbank (vom Administrator nachtraeglich gesetzt)', async () => {
|
||||
const strategy = new JwtStrategy(
|
||||
makeConfigService(),
|
||||
makePrisma({ ...dbRow, mustChangePassword: true }),
|
||||
);
|
||||
const result = await strategy.validate(payload);
|
||||
expect(result.mustChangePassword).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy } from 'passport-jwt';
|
||||
import { Request } from 'express';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { forTenant } from '../../prisma/prisma-tenant.extension';
|
||||
import type { AuthUser, JwtPayload } from '../types/auth-user';
|
||||
|
||||
/**
|
||||
@@ -17,7 +19,10 @@ function cookieExtractor(req: Request): string | null {
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(configService: ConfigService) {
|
||||
constructor(
|
||||
configService: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: cookieExtractor,
|
||||
ignoreExpiration: false,
|
||||
@@ -25,16 +30,46 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Token beweist nur, WER angemeldet ist — Rolle, Aktiv-Status und
|
||||
* Kennwort-Pflicht kommen bei JEDER Anfrage frisch aus der Datenbank
|
||||
* (quick-260930, Befund des Nutzers): vorher galt die Rolle aus dem
|
||||
* 30-Tage-Token. Ein herabgestufter Administrator behielt bis zum Ablauf
|
||||
* seine alten Rechte, ein deaktiviertes oder geloeschtes Konto (etwa per
|
||||
* LDAP-Abgleich beim Austritt) arbeitete mit seiner Sitzung weiter, und
|
||||
* Oberflaeche (liest die Rolle ueber /auth/me aus der Datenbank) und API
|
||||
* (las sie aus dem Token) sahen verschiedene Rollen — die Benutzerliste
|
||||
* scheiterte dann im Client.
|
||||
*
|
||||
* Ein Primaerschluessel-Lesezugriff je Anfrage, gebunden an den Mandanten
|
||||
* aus dem Token (`forTenant`); gehoert das Konto nicht (mehr) zu diesem
|
||||
* Mandanten, fehlt es oder ist es deaktiviert, gilt die Sitzung als
|
||||
* ungueltig (401) — die Web-Oberflaeche leitet dann zur Anmeldung.
|
||||
*/
|
||||
async validate(payload: JwtPayload): Promise<AuthUser> {
|
||||
const tenantPrisma = forTenant(this.prisma, payload.tenantId);
|
||||
const user = await tenantPrisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
role: true,
|
||||
tenantId: true,
|
||||
isActive: true,
|
||||
mustChangePassword: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !user.isActive || user.tenantId !== payload.tenantId) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
return {
|
||||
id: payload.sub,
|
||||
username: payload.username,
|
||||
role: payload.role,
|
||||
tenantId: payload.tenantId,
|
||||
// Ein vor dieser Aenderung ausgestelltes Token traegt diesen Anspruch
|
||||
// nicht; der strenge Vergleich ergibt dann false, laufende Sitzungen
|
||||
// verhalten sich unveraendert (260921-fi3, D-01 — keine Aussperrwelle).
|
||||
mustChangePassword: payload.mustChangePassword === true,
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role as AuthUser['role'],
|
||||
tenantId: user.tenantId,
|
||||
mustChangePassword: user.mustChangePassword === true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import { MailService } from './mail.service';
|
||||
import { MailService, WELCOME_HEADER_CID } from './mail.service';
|
||||
|
||||
/**
|
||||
* MailService.spec — NEU (260914-eym, Etappe 3c, WINDOWS #30). Der Bereich
|
||||
@@ -166,7 +166,11 @@ describe('MailService — Transport je Versand nach Mandant des Empfaengers (260
|
||||
const service = new MailService(settings as any, makeFakeConfig({}) as any);
|
||||
|
||||
await service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-a', 't1');
|
||||
await service.sendWelcomeEmail('bob@b.example.invalid', 'bob', 't2');
|
||||
await service.sendWelcomeMail('t2', 'bob@b.example.invalid', {
|
||||
subject: 'Willkommen bei Tessera',
|
||||
text: 'Guten Tag bob,',
|
||||
html: '<p>bob</p>',
|
||||
});
|
||||
|
||||
expect(settings.getDecryptedSmtpConfig.mock.calls.map((c) => c[0])).toEqual(['t1', 't2']);
|
||||
const transports = vi.mocked(nodemailer.createTransport).mock.calls.map((c) => c[0] as any);
|
||||
@@ -314,3 +318,47 @@ describe('MailService.sendReminderEmail (quick-260929-if2, E-04/E-07, T-IF2-05)'
|
||||
expect(mockClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MailService.sendWelcomeMail / hasConfiguredTransport (Willkommensmail)', () => {
|
||||
it('haengt das Kopfbild als CID-Anhang an, reicht HTML und Text durch und wirft Transportfehler nach aussen', async () => {
|
||||
const service = new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any);
|
||||
|
||||
await service.sendWelcomeMail('t1', 'neu@a.example.invalid', {
|
||||
subject: 'Willkommen bei Tessera',
|
||||
text: 'Text',
|
||||
html: `<img src="cid:${WELCOME_HEADER_CID}">`,
|
||||
});
|
||||
|
||||
const sent = mockSendMail.mock.calls[0][0] as any;
|
||||
expect(sent.from).toBe('noreply@a.example.invalid');
|
||||
expect(sent.html).toContain(`cid:${WELCOME_HEADER_CID}`);
|
||||
expect(sent.text).toBe('Text');
|
||||
expect(sent.attachments).toHaveLength(1);
|
||||
expect(sent.attachments[0].cid).toBe(WELCOME_HEADER_CID);
|
||||
expect(sent.attachments[0].contentType).toBe('image/png');
|
||||
// PNG-Signatur: das Bild aus apps/api/assets/mail ist wirklich geladen.
|
||||
expect((sent.attachments[0].content as Buffer).subarray(1, 4).toString()).toBe('PNG');
|
||||
|
||||
mockSendMail = vi.fn(async () => {
|
||||
throw new Error('ECONNREFUSED');
|
||||
});
|
||||
await expect(
|
||||
service.sendWelcomeMail('t1', 'neu@a.example.invalid', { subject: 'S', text: 'T', html: 'H' }),
|
||||
).rejects.toThrow('ECONNREFUSED');
|
||||
});
|
||||
|
||||
it('hasConfiguredTransport: SmtpConfig des Mandanten oder MAIL_HOST/TESSERA_SMTP_HOST — der Rueckfall localhost:1025 zaehlt nicht', async () => {
|
||||
expect(
|
||||
await new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any).hasConfiguredTransport('t1'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await new MailService(makeFakeSettings({}) as any, makeFakeConfig({ MAIL_HOST: 'smtp.example.invalid' }) as any).hasConfiguredTransport('t1'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await new MailService(makeFakeSettings({}) as any, makeFakeConfig({ TESSERA_SMTP_HOST: 'legacy.example.invalid' }) as any).hasConfiguredTransport('t1'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await new MailService(makeFakeSettings({}) as any, makeFakeConfig({}) as any).hasConfiguredTransport('t1'),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
@@ -45,15 +47,43 @@ import { SettingsService } from '../settings/settings.service';
|
||||
* Seit quick-260914-m97 (Fehler-melden-Knopf) ist der Versandkern
|
||||
* `deliver` herausgeloest: er WIRFT bei Transportfehlern und kennt
|
||||
* Anhaenge. `sendViaTenantTransport` bleibt der verschluckende Mantel fuer
|
||||
* Kennwort-Reset und Willkommensmail (T-02-12 unveraendert); `sendBugReport`
|
||||
* ruft den Kern direkt, damit der Anwender erfaehrt, ob sein Bericht ankam.
|
||||
* den Kennwort-Reset (T-02-12 unveraendert); `sendBugReport` und
|
||||
* `sendWelcomeMail` rufen den Kern direkt, damit der Ausloesende erfaehrt,
|
||||
* ob die Mail ging.
|
||||
*/
|
||||
|
||||
/** Inhaltskennung des Kopfbilds der Willkommensmail (`<img src="cid:...">`). */
|
||||
export const WELCOME_HEADER_CID = 'welcome-header@tessera';
|
||||
|
||||
let welcomeHeaderCache: Buffer | null | undefined;
|
||||
|
||||
/**
|
||||
* Laedt das Kopfbild der Willkommensmail einmal je Prozess
|
||||
* (apps/api/assets/mail/welcome-header.png, erzeugt von
|
||||
* scripts/render-mail-header.mjs). Zur Laufzeit liegt diese Datei unter
|
||||
* dist/mail/, im Test unter src/mail/ — beide Male zwei Ebenen unter
|
||||
* apps/api. Fehlt das Bild, liefert die Funktion `null`; die Mail zeigt
|
||||
* dann einen dunklen Textkopf statt abzubrechen.
|
||||
*/
|
||||
export function loadWelcomeHeaderPng(): Buffer | null {
|
||||
if (welcomeHeaderCache !== undefined) return welcomeHeaderCache;
|
||||
const file = path.resolve(__dirname, '..', '..', 'assets', 'mail', 'welcome-header.png');
|
||||
try {
|
||||
welcomeHeaderCache = fs.readFileSync(file);
|
||||
} catch {
|
||||
new Logger('MailService').warn(`Welcome header image missing: ${file}`);
|
||||
welcomeHeaderCache = null;
|
||||
}
|
||||
return welcomeHeaderCache;
|
||||
}
|
||||
|
||||
/** Anhang in der nodemailer-Form (`attachments` von `sendMail`). */
|
||||
export interface OutgoingAttachment {
|
||||
filename: string;
|
||||
content: Buffer;
|
||||
contentType: string;
|
||||
/** Inhaltskennung fuer eingebettete Bilder (`<img src="cid:...">`). */
|
||||
cid?: string;
|
||||
}
|
||||
|
||||
/** Eine ausgehende Mail, wie `deliver` sie an nodemailer reicht. */
|
||||
@@ -186,10 +216,10 @@ export class MailService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschluckender Mantel um `deliver` fuer Kennwort-Reset und
|
||||
* Willkommensmail: Fehler werden protokolliert, nie geworfen — der
|
||||
* Anmeldeweg antwortet weiter 200, keine E-Mail-Enumeration (T-02-12
|
||||
* bleibt fuer genau diese beiden Wege bestehen).
|
||||
* Verschluckender Mantel um `deliver` fuer den Kennwort-Reset: Fehler
|
||||
* werden protokolliert, nie geworfen — der Anmeldeweg antwortet weiter
|
||||
* 200, keine E-Mail-Enumeration (T-02-12). Die Willkommensmail benutzt
|
||||
* ihn seit dem Versand aus der Benutzerverwaltung nicht mehr.
|
||||
*/
|
||||
private async sendViaTenantTransport(
|
||||
tenantId: string,
|
||||
@@ -275,46 +305,59 @@ export class MailService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a welcome email to a newly created user (optional — derzeit ohne
|
||||
* Aufrufer, gemessen 260914-eym; bleibt als Pfad ueber denselben
|
||||
* Transport je Versand erhalten).
|
||||
* Willkommensmail aus der Benutzerverwaltung (Administrator → Benutzer,
|
||||
* "Willkommensmail senden"). Inhalt und HTML baut
|
||||
* `renderWelcomeMail` (welcome-mail.template.ts), die Entscheidung ueber
|
||||
* den Anmeldehinweis trifft `WelcomeMailService`. Diese Methode haengt
|
||||
* nur das Kopfbild als CID-Anhang an (`WELCOME_HEADER_CID`, kein
|
||||
* Nachladen von aussen) und versendet ueber den Transport des Mandanten
|
||||
* des EMPFAENGERS.
|
||||
*
|
||||
* @param tenantId - Mandant des Empfaengers (entscheidet ueber den SMTP-Transport)
|
||||
* Fehler gehen BEWUSST nach aussen (wie `sendBugReport`): ein
|
||||
* Administrator loest den Versand gezielt aus und muss erfahren, ob die
|
||||
* Mail ging — es gibt hier keinen Anmeldeweg, den eine Fehlermeldung
|
||||
* verraten koennte (T-02-12 betrifft nur Kennwort-Reset).
|
||||
*/
|
||||
async sendWelcomeEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
async sendWelcomeMail(
|
||||
tenantId: string,
|
||||
locale: string = 'de',
|
||||
to: string,
|
||||
mail: { subject: string; text: string; html: string },
|
||||
): Promise<void> {
|
||||
const isGerman = locale === 'de';
|
||||
const subject = isGerman
|
||||
? 'Willkommen bei Tessera'
|
||||
: 'Welcome to Tessera';
|
||||
|
||||
const text = isGerman
|
||||
const header = loadWelcomeHeaderPng();
|
||||
await this.deliver(
|
||||
tenantId,
|
||||
{
|
||||
to,
|
||||
...mail,
|
||||
attachments: header
|
||||
? [
|
||||
`Hallo ${username},`,
|
||||
'',
|
||||
'Ihr Tessera-Account wurde erstellt.',
|
||||
'',
|
||||
`Sie können sich unter ${this.appUrl}/login anmelden.`,
|
||||
'',
|
||||
'Mit freundlichen Grüßen,',
|
||||
'Ihr Tessera-Team',
|
||||
].join('\n')
|
||||
: [
|
||||
`Hello ${username},`,
|
||||
'',
|
||||
'Your Tessera account has been created.',
|
||||
'',
|
||||
`You can sign in at ${this.appUrl}/login.`,
|
||||
'',
|
||||
'Best regards,',
|
||||
'The Tessera Team',
|
||||
].join('\n');
|
||||
{
|
||||
filename: 'tessera.png',
|
||||
content: header,
|
||||
contentType: 'image/png',
|
||||
cid: WELCOME_HEADER_CID,
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
},
|
||||
'Welcome',
|
||||
);
|
||||
}
|
||||
|
||||
await this.sendViaTenantTransport(tenantId, { to: email, subject, text }, 'Welcome');
|
||||
/**
|
||||
* Gibt an, ob fuer den Mandanten ein Versandweg eingerichtet ist: eine
|
||||
* eigene SmtpConfig ODER ein per Umgebung gesetzter Server (MAIL_HOST /
|
||||
* TESSERA_SMTP_HOST). Der letzte Rueckfall `localhost:1025` (Mailhog in
|
||||
* der Entwicklung) zaehlt NICHT — sonst saehe die Oberflaeche einen
|
||||
* Versandweg, der im Betrieb ins Leere geht.
|
||||
*/
|
||||
async hasConfiguredTransport(tenantId: string): Promise<boolean> {
|
||||
const smtpConfig = await this.settingsService.getDecryptedSmtpConfig(tenantId);
|
||||
if (smtpConfig) return true;
|
||||
const envHost =
|
||||
this.configService.get<string>('MAIL_HOST') ??
|
||||
this.configService.get<string>('TESSERA_SMTP_HOST');
|
||||
return typeof envHost === 'string' && envHost.trim() !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* welcome-mail.template.ts — Inhalt und Gestaltung der Willkommensmail.
|
||||
*
|
||||
* Reine Funktion ohne Abhaengigkeiten: bekommt die fertigen Werte
|
||||
* (Anzeigename, Benutzername, Adresse, Anmeldeweg) und liefert Betreff,
|
||||
* Text-Alternative und HTML. Versand und Kopfbild-Anhang erledigt
|
||||
* `MailService.sendWelcomeMail`, die Entscheidung "wer bekommt welchen
|
||||
* Anmeldehinweis" `WelcomeMailService`.
|
||||
*
|
||||
* E-Mail-tauglich gebaut, weil Outlook (Word-Darstellung), Gmail und Apple
|
||||
* Mail sehr unterschiedlich darstellen:
|
||||
* - Tabellenlayout, alle Stile inline, hoechstens 600 px breit;
|
||||
* - keine externen Ressourcen, keine Web-Fonts (Systemschriften);
|
||||
* - Kopf: Bildmarke und Schriftzug als HTML (erscheinen immer), darunter
|
||||
* die Duenen-Welle als schmaler PNG-Streifen (`headerImageSrc`, im
|
||||
* Versand `cid:`), weil SVG und CSS-Hintergruende in Outlook nicht
|
||||
* erscheinen; fehlt das Bild, bleibt nur ein schmaler Abschluss;
|
||||
* - Knoepfe als Tabelle mit Hintergrundfarbe in der Zelle ("bulletproof"),
|
||||
* Outlook ignoriert Innenabstaende und Rundungen am Link selbst.
|
||||
*
|
||||
* Sicherheit: jeder eingesetzte Wert laeuft durch `escapeHtml`; Links werden
|
||||
* nur als http(s) uebernommen. Ein Kennwort steht NIE in der Mail — lokale
|
||||
* Konten bekommen einen Link zum Festlegen (Token wie beim
|
||||
* "Passwort vergessen"-Weg), verzeichnisgefuehrte den Hinweis auf das
|
||||
* Windows-Passwort.
|
||||
*/
|
||||
|
||||
/** Farben aus dem Design "Mosaik" (globals.css / brand.ts). */
|
||||
const C = {
|
||||
page: '#eceef1',
|
||||
card: '#ffffff',
|
||||
ink: '#1a1d21',
|
||||
body: '#3d4450',
|
||||
muted: '#6b7280',
|
||||
line: '#e3e5e9',
|
||||
well: '#f7f7f5',
|
||||
yellow: '#ffed00',
|
||||
link: '#1d5fc2',
|
||||
header: '#1a1c20',
|
||||
} as const;
|
||||
|
||||
const FONT =
|
||||
"'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', Arial, sans-serif";
|
||||
|
||||
/** Anmeldeweg des Empfaengers — entscheidet den Hinweis in der Mail. */
|
||||
export type WelcomeMailAccount =
|
||||
| { kind: 'directory' }
|
||||
| { kind: 'local'; setPasswordUrl: string; validHours: number }
|
||||
| { kind: 'local-no-link' };
|
||||
|
||||
export interface WelcomeMailInput {
|
||||
/** Anzeigename, sonst Benutzername. */
|
||||
name: string;
|
||||
username: string;
|
||||
/** Oeffentliche Basisadresse der Web-Oberflaeche, ohne abschliessenden Schraegstrich. */
|
||||
appUrl: string;
|
||||
account: WelcomeMailAccount;
|
||||
/** `cid:...` im Versand, relativer Pfad in der Vorschau, `null` = Textkopf. */
|
||||
headerImageSrc: string | null;
|
||||
}
|
||||
|
||||
export interface RenderedWelcomeMail {
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export const WELCOME_MAIL_SUBJECT = 'Willkommen bei Tessera';
|
||||
|
||||
const FOOTER = 'Diese E-Mail wurde von Tessera im Auftrag Ihres Administrators versendet.';
|
||||
const INTRO =
|
||||
'für Sie wurde ein Zugang zu Tessera eingerichtet – Ihrer zentralen Plattform für Werkzeuge und Abläufe im Unternehmen. Alles, was Sie für Ihre tägliche Arbeit brauchen, finden Sie dort an einem Ort.';
|
||||
const DESKTOP_HINT =
|
||||
'Tessera gibt es auch als Desktop-App – den Download finden Sie auf der Anmeldeseite.';
|
||||
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** Nur http(s)-Adressen gelangen in ein href; alles andere wird leer. */
|
||||
function safeUrl(value: string): string {
|
||||
return /^https?:\/\//i.test(value) ? value : '';
|
||||
}
|
||||
|
||||
function loginHintText(account: WelcomeMailAccount): string {
|
||||
switch (account.kind) {
|
||||
case 'directory':
|
||||
return 'Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten Windows-Passwort an.';
|
||||
case 'local':
|
||||
return 'Bevor Sie sich zum ersten Mal anmelden, legen Sie bitte Ihr persönliches Passwort fest.';
|
||||
case 'local-no-link':
|
||||
return 'Ihr Startpasswort erhalten Sie von Ihrem Administrator.';
|
||||
}
|
||||
}
|
||||
|
||||
function validityText(hours: number): string {
|
||||
const span =
|
||||
hours % 24 === 0 && hours >= 24
|
||||
? hours === 24
|
||||
? '1 Tag'
|
||||
: `${hours / 24} Tage`
|
||||
: hours === 1
|
||||
? '1 Stunde'
|
||||
: `${hours} Stunden`;
|
||||
return `Der Link ist ${span} gültig und nur einmal verwendbar. Ist er abgelaufen, fordern Sie auf der Anmeldeseite über „Passwort vergessen?“ einfach einen neuen an.`;
|
||||
}
|
||||
|
||||
/** Knopf als Tabelle: Farbe an der Zelle, damit Outlook ihn als Flaeche zeigt. */
|
||||
function button(href: string, label: string, bg: string, fg: string): string {
|
||||
return `<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse:separate;">
|
||||
<tr><td align="center" bgcolor="${bg}" style="background-color:${bg};border-radius:6px;mso-padding-alt:14px 30px;">
|
||||
<a href="${escapeHtml(href)}" target="_blank" style="display:inline-block;padding:14px 30px;font-family:${FONT};font-size:16px;line-height:20px;font-weight:600;color:${fg};text-decoration:none;border-radius:6px;">${escapeHtml(label)}</a>
|
||||
</td></tr></table>`;
|
||||
}
|
||||
|
||||
/** Eine Kachel der Bildmarke: feste Zelle, Hoehe auch in Outlook exakt. */
|
||||
function tile(color: string | null): string {
|
||||
const bg = color ? `bgcolor="${color}" style="background-color:${color};` : 'style="';
|
||||
return `<td width="9" height="9" ${bg}width:9px;height:9px;font-size:1px;line-height:9px;mso-line-height-rule:exactly;"> </td>`;
|
||||
}
|
||||
|
||||
/** Luecke zwischen Kacheln. */
|
||||
const GAP = '<td width="3" style="width:3px;font-size:1px;line-height:1px;"> </td>';
|
||||
|
||||
/**
|
||||
* Bildmarke als HTML (quick-260930): das Kachel-"T" aus Tabellenzellen —
|
||||
* oben drei Kacheln (die dritte gelb, im Original gedreht), darunter zwei in
|
||||
* der Mitte —, auf dunkler Grundplatte mit heller Kontur wie in der App.
|
||||
* Braucht kein Bild und erscheint deshalb in jedem Mailprogramm.
|
||||
*/
|
||||
function logoMark(): string {
|
||||
const row = (cells: Array<string | null>) =>
|
||||
`<tr>${cells.map((c, i) => (i > 0 ? GAP : '') + tile(c)).join('')}</tr>`;
|
||||
const spacer = `<tr><td colspan="5" height="3" style="height:3px;font-size:1px;line-height:3px;mso-line-height-rule:exactly;"> </td></tr>`;
|
||||
const olive = '#9c9440';
|
||||
return `<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse:separate;">
|
||||
<tr><td bgcolor="#111214" style="background-color:#111214;border:1px solid #3a3d44;border-radius:10px;padding:10px 10px 10px 10px;">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
|
||||
${row([olive, olive, C.yellow])}${spacer}${row([null, olive, null])}${spacer}${row([null, olive, null])}
|
||||
</table>
|
||||
</td></tr></table>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kopf der Mail (quick-260930, Rueckmeldung des Nutzers: in Outlook "ein
|
||||
* riesiger schwarzer Fleck, kein Logo"). Vorher steckten Logo und
|
||||
* Schriftzug in EINEM 150 px hohen Bild; zeigt ein Mailprogramm das
|
||||
* eingebettete Bild nicht an, blieb nur die dunkle Flaeche. Jetzt:
|
||||
* - Bildmarke (HTML-Kacheln) und Schriftzug "Tessera" (echter Text) in einer
|
||||
* niedrigen dunklen Leiste — erscheinen immer;
|
||||
* - darunter die Duenen-Welle als schmaler Bildstreifen (`src`, im Versand
|
||||
* `cid:`), der ins Weiss der Karte auslaeuft. Fehlt er, bleibt nur weisser
|
||||
* Abstand, kein dunkler Block.
|
||||
*/
|
||||
function headerRow(src: string | null): string {
|
||||
const bar = `<tr><td bgcolor="${C.header}" style="background-color:${C.header};border-radius:12px 12px 0 0;padding:22px 32px 14px;">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr>
|
||||
<td valign="middle" style="padding:0 14px 0 0;">${logoMark()}</td>
|
||||
<td valign="middle" style="font-family:${FONT};font-size:26px;line-height:32px;font-weight:700;color:#ffffff;letter-spacing:-0.5px;">Tessera</td>
|
||||
</tr></table>
|
||||
</td></tr>`;
|
||||
const wave = src
|
||||
? `<tr><td bgcolor="${C.card}" style="background-color:${C.card};line-height:0;font-size:0;">
|
||||
<img src="${escapeHtml(src)}" width="600" height="56" alt="" style="display:block;width:100%;max-width:600px;height:auto;border:0;outline:none;text-decoration:none;">
|
||||
</td></tr>`
|
||||
: `<tr><td bgcolor="${C.header}" height="4" style="background-color:${C.header};height:4px;font-size:1px;line-height:4px;border-bottom:3px solid ${C.yellow};"> </td></tr>`;
|
||||
return bar + wave;
|
||||
}
|
||||
|
||||
export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail {
|
||||
const base = safeUrl(input.appUrl.replace(/\/+$/, ''));
|
||||
const loginUrl = `${base}/login`;
|
||||
const hint = loginHintText(input.account);
|
||||
|
||||
// ── Text-Alternative ───────────────────────────────────────────────────
|
||||
const textLines = [
|
||||
`Guten Tag ${input.name},`,
|
||||
'',
|
||||
INTRO,
|
||||
'',
|
||||
'Ihre Zugangsdaten',
|
||||
`Adresse: ${base}`,
|
||||
`Benutzername: ${input.username}`,
|
||||
'',
|
||||
hint,
|
||||
];
|
||||
if (input.account.kind === 'local') {
|
||||
textLines.push(
|
||||
'',
|
||||
'Passwort festlegen:',
|
||||
input.account.setPasswordUrl,
|
||||
validityText(input.account.validHours),
|
||||
);
|
||||
}
|
||||
textLines.push(
|
||||
'',
|
||||
'Zu Tessera:',
|
||||
loginUrl,
|
||||
'',
|
||||
DESKTOP_HINT,
|
||||
'',
|
||||
'Viel Erfolg mit Tessera!',
|
||||
'',
|
||||
'--',
|
||||
FOOTER,
|
||||
);
|
||||
const text = textLines.join('\n');
|
||||
|
||||
// ── HTML ───────────────────────────────────────────────────────────────
|
||||
const name = escapeHtml(input.name);
|
||||
const username = escapeHtml(input.username);
|
||||
const baseHtml = escapeHtml(base);
|
||||
|
||||
const p = (content: string, extra = '') =>
|
||||
`<p style="margin:0 0 16px;font-family:${FONT};font-size:16px;line-height:25px;color:${C.body};${extra}">${content}</p>`;
|
||||
|
||||
const label = (content: string) =>
|
||||
`<div style="font-family:${FONT};font-size:12px;line-height:16px;font-weight:600;letter-spacing:0.06em;text-transform:uppercase;color:${C.muted};">${content}</div>`;
|
||||
|
||||
let accountBlock = p(escapeHtml(hint), 'margin:24px 0 16px;');
|
||||
if (input.account.kind === 'local') {
|
||||
const setUrl = safeUrl(input.account.setPasswordUrl);
|
||||
accountBlock += `${button(setUrl, 'Passwort festlegen', C.ink, '#ffffff')}
|
||||
<p style="margin:12px 0 0;font-family:${FONT};font-size:13px;line-height:20px;color:${C.muted};">${escapeHtml(validityText(input.account.validHours))}</p>`;
|
||||
}
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="supported-color-schemes" content="light">
|
||||
<title>${escapeHtml(WELCOME_MAIL_SUBJECT)}</title>
|
||||
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
|
||||
<style>
|
||||
a { color: ${C.link}; }
|
||||
@media only screen and (max-width: 620px) {
|
||||
.tsr-pad { padding-left: 24px !important; padding-right: 24px !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:${C.page};-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
|
||||
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:${C.page};">Ihr Zugang zu Tessera ist eingerichtet – hier finden Sie Adresse und Benutzername.</div>
|
||||
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" bgcolor="${C.page}" style="background-color:${C.page};">
|
||||
<tr><td align="center" style="padding:32px 12px;">
|
||||
<!--[if mso]><table role="presentation" width="600" border="0" cellpadding="0" cellspacing="0"><tr><td><![endif]-->
|
||||
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" style="width:100%;max-width:600px;border-collapse:separate;">
|
||||
${headerRow(input.headerImageSrc)}
|
||||
<tr><td class="tsr-pad" bgcolor="${C.card}" style="background-color:${C.card};padding:36px 40px 12px;">
|
||||
<h1 style="margin:0 0 16px;font-family:${FONT};font-size:24px;line-height:32px;font-weight:700;color:${C.ink};">Willkommen bei Tessera, ${name}!</h1>
|
||||
${p(escapeHtml(INTRO.charAt(0).toUpperCase() + INTRO.slice(1)))}
|
||||
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" style="border-collapse:separate;margin:8px 0 0;">
|
||||
<tr><td bgcolor="${C.well}" style="background-color:${C.well};border:1px solid ${C.line};border-left:4px solid ${C.yellow};border-radius:8px;padding:18px 22px;">
|
||||
${label('Adresse')}
|
||||
<div style="margin:4px 0 14px;font-family:${FONT};font-size:16px;line-height:24px;font-weight:600;"><a href="${escapeHtml(loginUrl)}" target="_blank" style="color:${C.link};text-decoration:none;">${baseHtml}</a></div>
|
||||
${label('Benutzername')}
|
||||
<div style="margin:4px 0 0;font-family:Consolas,'SF Mono',Menlo,'Courier New',monospace;font-size:16px;line-height:24px;font-weight:600;color:${C.ink};">${username}</div>
|
||||
</td></tr></table>
|
||||
${accountBlock}
|
||||
</td></tr>
|
||||
<tr><td class="tsr-pad" bgcolor="${C.card}" align="left" style="background-color:${C.card};padding:20px 40px 8px;">
|
||||
${button(loginUrl, 'Zu Tessera', C.yellow, C.ink)}
|
||||
</td></tr>
|
||||
<tr><td class="tsr-pad" bgcolor="${C.card}" style="background-color:${C.card};padding:20px 40px 36px;border-radius:0 0 12px 12px;">
|
||||
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0"><tr><td style="border-top:1px solid ${C.line};padding-top:20px;">
|
||||
${p(`<strong style="color:${C.ink};">Tipp:</strong> ${escapeHtml(DESKTOP_HINT)}`, `font-size:14px;line-height:22px;margin:0 0 12px;`)}
|
||||
${p('Viel Erfolg mit Tessera!', 'font-size:14px;line-height:22px;margin:0;')}
|
||||
</td></tr></table>
|
||||
</td></tr>
|
||||
<tr><td align="center" style="padding:20px 24px 0;font-family:${FONT};font-size:12px;line-height:18px;color:${C.muted};">${escapeHtml(FOOTER)}</td></tr>
|
||||
</table>
|
||||
<!--[if mso]></td></tr></table><![endif]-->
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return { subject: WELCOME_MAIL_SUBJECT, text, html };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
@@ -124,11 +124,13 @@ describe('UserController', () => {
|
||||
let prisma: any;
|
||||
let userService: any;
|
||||
let controller: UserController;
|
||||
let welcomeMail: { isAvailable: ReturnType<typeof vi.fn>; send: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = makeFakePrisma();
|
||||
userService = makeUserServiceMock();
|
||||
controller = new UserController(userService as any, prisma);
|
||||
welcomeMail = { isAvailable: vi.fn(), send: vi.fn() };
|
||||
controller = new UserController(userService as any, prisma, welcomeMail as any);
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
@@ -663,4 +665,80 @@ describe('UserController', () => {
|
||||
expect(await storedFor('t2', 'u-other')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Willkommensmail (POST :id/welcome-mail, GET welcome-mail/status)', () => {
|
||||
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
|
||||
const superAdmin = { role: Role.SUPER_ADMIN, tenantId: 't9', id: 'super1', username: 'super1', mustChangePassword: false };
|
||||
const sentAt = new Date('2026-09-30T10:00:00.000Z');
|
||||
|
||||
it('ADMIN: Ziel gebunden an den eigenen Mandanten aufgeloest, Dienst mit Ziel und Origin gerufen, Antwort traegt Empfaenger und Zeitpunkt', async () => {
|
||||
const target = { id: 'u-x', username: 'x', tenantId: 't1', role: 'USER', lastLoginAt: null };
|
||||
userService.findById.mockResolvedValue(target);
|
||||
welcomeMail.send.mockResolvedValue({ to: 'x@x.invalid', welcomeMailSentAt: sentAt });
|
||||
|
||||
const result = await controller.sendWelcomeMail('u-x', admin, 'https://tessera.example.invalid');
|
||||
|
||||
expect(userService.findById).toHaveBeenCalledWith('t1', 'u-x');
|
||||
expect(welcomeMail.send).toHaveBeenCalledWith(target, 'https://tessera.example.invalid');
|
||||
expect(result).toEqual({ success: true, to: 'x@x.invalid', welcomeMailSentAt: sentAt });
|
||||
});
|
||||
|
||||
it('ADMIN: unbekannter oder fremdmandantiger Benutzer (gebundene Aufloesung null) → NotFoundException, kein Versand', async () => {
|
||||
userService.findById.mockResolvedValue(null);
|
||||
await expect(controller.sendWelcomeMail('u-fremd', admin)).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(welcomeMail.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ADMIN: Ziel eines fremden Mandanten (zweite Schicht) → ForbiddenException mit Mandanten-Meldung, kein Versand', async () => {
|
||||
userService.findById.mockResolvedValue({ id: 'u-y', tenantId: 't2', role: 'SUPER_ADMIN' });
|
||||
await expect(controller.sendWelcomeMail('u-y', admin)).rejects.toThrow('Cannot modify users from other tenants');
|
||||
expect(welcomeMail.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ADMIN: Ziel ist SUPER_ADMIN des eigenen Mandanten → ForbiddenException (Zielrollen-Riegel), kein Versand', async () => {
|
||||
userService.findById.mockResolvedValue({ id: 'u-s', tenantId: 't1', role: 'SUPER_ADMIN' });
|
||||
await expect(controller.sendWelcomeMail('u-s', admin)).rejects.toThrow('Cannot modify a SUPER_ADMIN user');
|
||||
expect(welcomeMail.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SUPER_ADMIN: Ziel ueber die uebergreifende Aufloesung, auch in fremdem Mandanten und mit oberster Rolle', async () => {
|
||||
const target = { id: 'u-z', tenantId: 't2', role: 'SUPER_ADMIN', lastLoginAt: null };
|
||||
userService.findByIdForPlatformAdmin.mockResolvedValue(target);
|
||||
welcomeMail.send.mockResolvedValue({ to: 'z@x.invalid', welcomeMailSentAt: sentAt });
|
||||
|
||||
await controller.sendWelcomeMail('u-z', superAdmin);
|
||||
|
||||
expect(userService.findByIdForPlatformAdmin).toHaveBeenCalledWith('u-z');
|
||||
expect(welcomeMail.send).toHaveBeenCalledWith(target, undefined);
|
||||
});
|
||||
|
||||
it('Fehler des Dienstes (z. B. 409 Konto deaktiviert) gehen unveraendert nach aussen', async () => {
|
||||
userService.findById.mockResolvedValue({ id: 'u-x', tenantId: 't1', role: 'USER' });
|
||||
welcomeMail.send.mockRejectedValue(new ConflictException('deaktiviert'));
|
||||
await expect(controller.sendWelcomeMail('u-x', admin)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('GET welcome-mail/status fragt den Versandweg fuer den Mandanten des Aufrufers ab', async () => {
|
||||
welcomeMail.isAvailable.mockResolvedValue(true);
|
||||
await expect(controller.getWelcomeMailStatus(admin)).resolves.toEqual({ available: true });
|
||||
expect(welcomeMail.isAvailable).toHaveBeenCalledWith('t1');
|
||||
});
|
||||
|
||||
it('findAll (ADMIN) liefert welcomeMailSentAt mit', async () => {
|
||||
prisma.__seedUser({
|
||||
id: 'u-a',
|
||||
username: 'alice',
|
||||
tenantId: 't1',
|
||||
email: 'alice@x.invalid',
|
||||
displayName: null,
|
||||
role: 'USER',
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
lastLoginAt: null,
|
||||
welcomeMailSentAt: sentAt,
|
||||
});
|
||||
const result = await controller.findAll(admin);
|
||||
expect((result as any[])[0].welcomeMailSentAt).toBe(sentAt);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
Headers,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Param,
|
||||
@@ -39,6 +40,7 @@ import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { ReleaseSeenDto } from './dto/release-seen.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { UserService } from './user.service';
|
||||
import { WelcomeMailService } from './welcome-mail.service';
|
||||
|
||||
/** Map accepted MIME types to file extensions (T-gbh-01). */
|
||||
const AVATAR_MIME_TO_EXT: Record<string, string> = {
|
||||
@@ -68,6 +70,10 @@ function resolveAvatarsDir(): string {
|
||||
* quick-260928-ujj: dazu kommt ein gebundener Zugriff des
|
||||
* Selbstbedienungswegs `PATCH me/dashboard-background`, ebenfalls
|
||||
* `forTenant()` mit `where: { id: currentUser.id }`.
|
||||
*
|
||||
* Willkommensmail: `POST :id/welcome-mail` loest den Zielbenutzer ueber
|
||||
* `resolveTargetUser` auf; die beiden Schreibzugriffe (Token, Versandzeit)
|
||||
* liegen gebunden in `WelcomeMailService`, nicht in diesem Controller.
|
||||
*/
|
||||
@Controller('users')
|
||||
@UseGuards(RolesGuard)
|
||||
@@ -75,6 +81,7 @@ export class UserController {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly welcomeMailService: WelcomeMailService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -126,6 +133,7 @@ export class UserController {
|
||||
tenantId: true,
|
||||
createdAt: true,
|
||||
lastLoginAt: true,
|
||||
welcomeMailSentAt: true,
|
||||
},
|
||||
orderBy: { username: 'asc' },
|
||||
});
|
||||
@@ -218,6 +226,23 @@ export class UserController {
|
||||
return { success: true, lastSeenReleaseVersion: version };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /users/welcome-mail/status (Willkommensmail)
|
||||
*
|
||||
* Ob fuer den Mandanten des Aufrufers ein Versandweg eingerichtet ist —
|
||||
* die Oberflaeche deaktiviert damit den Knopf "Willkommensmail senden"
|
||||
* und nennt den Grund. Die wirksame Pruefung bleibt beim Versand selbst
|
||||
* (`POST :id/welcome-mail`, dort je Mandant des ZIELS). Statische Route
|
||||
* steht VOR der Kennungs-Route (GET :id).
|
||||
*/
|
||||
@Get('welcome-mail/status')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async getWelcomeMailStatus(
|
||||
@CurrentUser() currentUser: AuthUser,
|
||||
): Promise<{ available: boolean }> {
|
||||
return { available: await this.welcomeMailService.isAvailable(currentUser.tenantId) };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /users/:id
|
||||
*/
|
||||
@@ -330,6 +355,47 @@ export class UserController {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /users/:id/welcome-mail (Willkommensmail)
|
||||
*
|
||||
* Schickt einem Benutzer (egal ob schon angemeldet) eine
|
||||
* Willkommensmail (Adresse, Benutzername, Anmeldehinweis je Kontoart) und
|
||||
* merkt den Zeitpunkt in `welcomeMailSentAt`. Dieselben Regeln wie beim
|
||||
* Bearbeiten: ADMIN nur im eigenen Mandanten, SUPER_ADMIN uebergreifend;
|
||||
* ein Nicht-SUPER_ADMIN darf einem SUPER_ADMIN nichts schicken
|
||||
* (Zielrollen-Riegel wie in update()). Deaktiviert -> 409, ohne
|
||||
* Adresse -> 400, ohne Versandweg -> 409, Versandfehler -> 502 (alles in
|
||||
* `WelcomeMailService`). Der Origin-Kopf dient nur als Rueckfall fuer die
|
||||
* Adresse in der Mail, wenn `TESSERA_APP_URL` fehlt.
|
||||
*/
|
||||
@Post(':id/welcome-mail')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async sendWelcomeMail(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() currentUser: AuthUser,
|
||||
@Headers('origin') origin?: string,
|
||||
): Promise<{ success: true; to: string; welcomeMailSentAt: Date }> {
|
||||
const user = await this.resolveTargetUser(currentUser, id);
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
if (
|
||||
currentUser.role !== Role.SUPER_ADMIN &&
|
||||
user.tenantId !== currentUser.tenantId
|
||||
) {
|
||||
throw new ForbiddenException('Cannot modify users from other tenants');
|
||||
}
|
||||
|
||||
if (user.role === Role.SUPER_ADMIN && currentUser.role !== Role.SUPER_ADMIN) {
|
||||
throw new ForbiddenException('Cannot modify a SUPER_ADMIN user');
|
||||
}
|
||||
|
||||
const result = await this.welcomeMailService.send(user, origin);
|
||||
return { success: true, ...result };
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /users/:id
|
||||
* ADMIN cannot delete self or users from other tenants.
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GroupsModule } from '../groups/groups.module';
|
||||
import { MailModule } from '../mail/mail.module';
|
||||
import { AdminSeedService } from './admin-seed.service';
|
||||
import { UserController } from './user.controller';
|
||||
import { UserService } from './user.service';
|
||||
import { WelcomeMailService } from './welcome-mail.service';
|
||||
|
||||
/**
|
||||
* Importiert GroupsModule für UserService.create's Standardgruppen-
|
||||
* Mitgliedschaft (D-11/D-12). GroupsModule importiert seinerseits
|
||||
* UserModule nicht — GroupsService greift für Benutzerprüfungen direkt
|
||||
* auf PrismaService zu, keine Zirkularität.
|
||||
*
|
||||
* Importiert MailModule für die Willkommensmail (`WelcomeMailService`).
|
||||
* Zyklusfrei: MailModule -> SettingsModule, keiner von beiden importiert
|
||||
* UserModule.
|
||||
*/
|
||||
@Module({
|
||||
imports: [GroupsModule],
|
||||
imports: [GroupsModule, MailModule],
|
||||
controllers: [UserController],
|
||||
providers: [UserService, AdminSeedService],
|
||||
providers: [UserService, AdminSeedService, WelcomeMailService],
|
||||
exports: [UserService],
|
||||
})
|
||||
export class UserModule {}
|
||||
|
||||
@@ -37,6 +37,7 @@ const PLATFORM_USER_SELECT = {
|
||||
tenantId: true,
|
||||
createdAt: true,
|
||||
lastLoginAt: true,
|
||||
welcomeMailSentAt: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { BadGatewayException, BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { normalizeOrigin, type WelcomeMailTarget, WelcomeMailService } from './welcome-mail.service';
|
||||
|
||||
/**
|
||||
* WelcomeMailService — Willkommensmail aus der Benutzerverwaltung.
|
||||
*
|
||||
* Festgenagelt: Versand auch an bereits angemeldete Benutzer; 409 fuer
|
||||
* deaktivierte Benutzer,
|
||||
* 400 ohne Adresse, 409 ohne Versandweg, 502 bei Versandfehler (und dann
|
||||
* KEIN `welcomeMailSentAt`); beide Schreibzugriffe gebunden an den
|
||||
* Mandanten des ZIELS; Inhalt je Kontoart (verzeichnisgefuehrt: Windows-
|
||||
* Passwort, kein Token; lokal: Token-Link mit der Frist des
|
||||
* "Passwort vergessen"-Wegs); Adresse aus Konfiguration bzw. Origin.
|
||||
*/
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__bound(tenantId)),
|
||||
}));
|
||||
|
||||
function makePrisma() {
|
||||
const log: { tenantId: string; model: string; method: string; args: any }[] = [];
|
||||
return {
|
||||
__log: log,
|
||||
__bound(tenantId: string) {
|
||||
return {
|
||||
passwordResetToken: {
|
||||
create: vi.fn(async (args: any) => {
|
||||
log.push({ tenantId, model: 'passwordResetToken', method: 'create', args });
|
||||
return { id: 'tok-row', ...args.data };
|
||||
}),
|
||||
},
|
||||
user: {
|
||||
update: vi.fn(async (args: any) => {
|
||||
log.push({ tenantId, model: 'user', method: 'update', args });
|
||||
return { welcomeMailSentAt: args.data.welcomeMailSentAt };
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeMail(opts: { available?: boolean; fail?: boolean } = {}) {
|
||||
return {
|
||||
hasConfiguredTransport: vi.fn(async () => opts.available ?? true),
|
||||
sendWelcomeMail: vi.fn(async () => {
|
||||
if (opts.fail) throw new Error('ECONNREFUSED');
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeConfig(values: Record<string, string | undefined>) {
|
||||
return { get: vi.fn((key: string) => values[key]) };
|
||||
}
|
||||
|
||||
const localUser: WelcomeMailTarget = {
|
||||
id: 'u-local',
|
||||
tenantId: 't1',
|
||||
username: 'max.muster',
|
||||
displayName: 'Max Muster',
|
||||
email: 'max@example.invalid',
|
||||
ldapDn: null,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const ldapUser: WelcomeMailTarget = {
|
||||
...localUser,
|
||||
id: 'u-ldap',
|
||||
username: 'erika',
|
||||
displayName: null,
|
||||
email: 'erika@example.invalid',
|
||||
ldapDn: 'CN=Erika,OU=Users,DC=example,DC=invalid',
|
||||
};
|
||||
|
||||
let prisma: ReturnType<typeof makePrisma>;
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = makePrisma();
|
||||
});
|
||||
|
||||
function make(mail = makeMail(), config = makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid' })) {
|
||||
const service = new WelcomeMailService(prisma as any, mail as any, config as any);
|
||||
vi.spyOn((service as any).logger, 'log').mockImplementation(() => undefined);
|
||||
vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined);
|
||||
return { service, mail };
|
||||
}
|
||||
|
||||
describe('WelcomeMailService.send — Vorbedingungen', () => {
|
||||
it('bereits angemeldete Benutzer jeder Rolle → Versand erlaubt, welcomeMailSentAt gesetzt', async () => {
|
||||
const { service, mail } = make();
|
||||
await service.send({ ...ldapUser, lastLoginAt: new Date() } as WelcomeMailTarget);
|
||||
await service.send({ ...localUser, lastLoginAt: new Date(), role: 'SUPER_ADMIN' } as WelcomeMailTarget);
|
||||
expect(mail.sendWelcomeMail).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.__log.filter((c) => c.model === 'user')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('deaktiviertes Konto → ConflictException', async () => {
|
||||
const { service, mail } = make();
|
||||
await expect(service.send({ ...localUser, isActive: false })).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ohne E-Mail-Adresse → BadRequestException', async () => {
|
||||
const { service, mail } = make();
|
||||
await expect(service.send({ ...localUser, email: null })).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ohne Versandweg → ConflictException mit Hinweis auf Administrator → SMTP; Pruefung im Mandanten des Ziels', async () => {
|
||||
const mail = makeMail({ available: false });
|
||||
const { service } = make(mail);
|
||||
await expect(service.send(localUser)).rejects.toThrow('Administrator → SMTP');
|
||||
expect(mail.hasConfiguredTransport).toHaveBeenCalledWith('t1');
|
||||
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
|
||||
expect(prisma.__log).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WelcomeMailService.send — Versand', () => {
|
||||
it('lokales Konto: Token gebunden an den Mandanten des Ziels, Frist 7 Tage, Link in Text und HTML, kein Kennwort; welcomeMailSentAt gebunden gesetzt', async () => {
|
||||
const { service, mail } = make();
|
||||
const before = Date.now();
|
||||
|
||||
const result = await service.send(localUser);
|
||||
|
||||
const tokenCall = prisma.__log.find((c) => c.model === 'passwordResetToken');
|
||||
expect(tokenCall?.tenantId).toBe('t1');
|
||||
expect(tokenCall?.args.data.userId).toBe('u-local');
|
||||
const ttl = (tokenCall as { args: any }).args.data.expiresAt.getTime() - before;
|
||||
expect(ttl).toBeGreaterThan(7 * 24 * 60 * 60 * 1000 - 60 * 1000);
|
||||
expect(ttl).toBeLessThanOrEqual(7 * 24 * 60 * 60 * 1000 + 1000);
|
||||
|
||||
expect(mail.sendWelcomeMail).toHaveBeenCalledTimes(1);
|
||||
const [tenantId, to, rendered] = mail.sendWelcomeMail.mock.calls[0] as any[];
|
||||
expect(tenantId).toBe('t1');
|
||||
expect(to).toBe('max@example.invalid');
|
||||
const token = (tokenCall as { args: any }).args.data.token;
|
||||
expect(rendered.text).toContain(`https://tessera.example.invalid/reset-password/${token}`);
|
||||
expect(rendered.html).toContain(`https://tessera.example.invalid/reset-password/${token}`);
|
||||
expect(rendered.html).toContain('Passwort festlegen');
|
||||
expect(rendered.html).toContain('Willkommen bei Tessera, Max Muster!');
|
||||
expect(rendered.text).toContain('Benutzername: max.muster');
|
||||
expect(rendered.html).toContain('https://tessera.example.invalid/login');
|
||||
expect(rendered.text).not.toContain('Windows-Passwort');
|
||||
|
||||
const updateCall = prisma.__log.find((c) => c.model === 'user' && c.method === 'update');
|
||||
expect(updateCall?.tenantId).toBe('t1');
|
||||
expect(updateCall?.args.where).toEqual({ id: 'u-local' });
|
||||
expect(updateCall?.args.data.welcomeMailSentAt).toBeInstanceOf(Date);
|
||||
expect(result.to).toBe('max@example.invalid');
|
||||
expect(result.welcomeMailSentAt).toBe(updateCall?.args.data.welcomeMailSentAt);
|
||||
});
|
||||
|
||||
it('verzeichnisgefuehrtes Konto: kein Token, Hinweis auf das Windows-Passwort, Anrede mit Benutzername ohne Anzeigenamen', async () => {
|
||||
const { service, mail } = make();
|
||||
|
||||
await service.send(ldapUser);
|
||||
|
||||
expect(prisma.__log.some((c) => c.model === 'passwordResetToken')).toBe(false);
|
||||
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
|
||||
expect(rendered.text).toContain('gewohnten Windows-Passwort');
|
||||
expect(rendered.html).toContain('gewohnten Windows-Passwort');
|
||||
expect(rendered.html).not.toContain('reset-password');
|
||||
expect(rendered.html).not.toContain('Passwort festlegen');
|
||||
expect(rendered.html).toContain('Willkommen bei Tessera, erika!');
|
||||
});
|
||||
|
||||
it('Versandfehler → BadGatewayException, welcomeMailSentAt wird NICHT gesetzt', async () => {
|
||||
const { service } = make(makeMail({ fail: true }));
|
||||
await expect(service.send(ldapUser)).rejects.toBeInstanceOf(BadGatewayException);
|
||||
expect(prisma.__log.some((c) => c.model === 'user')).toBe(false);
|
||||
});
|
||||
|
||||
it('Anzeigename mit Markup wird im HTML maskiert', async () => {
|
||||
const { service, mail } = make();
|
||||
await service.send({ ...ldapUser, displayName: '<b>Böse</b>' });
|
||||
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
|
||||
expect(rendered.html).not.toContain('<b>Böse</b>');
|
||||
expect(rendered.html).toContain('<b>Böse</b>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WelcomeMailService.resolveAppUrl', () => {
|
||||
it('Konfiguration gewinnt vor dem Origin; abschliessender Schraegstrich faellt weg', () => {
|
||||
const { service } = make(makeMail(), makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid/' }));
|
||||
expect(service.resolveAppUrl('https://anders.example.invalid')).toBe('https://tessera.example.invalid');
|
||||
});
|
||||
|
||||
it('ohne Konfiguration: Origin der Anfrage', () => {
|
||||
const { service } = make(makeMail(), makeConfig({}));
|
||||
expect(service.resolveAppUrl('https://alpha.example.invalid')).toBe('https://alpha.example.invalid');
|
||||
});
|
||||
|
||||
it('Konfiguration zeigt nur auf localhost (Compose-Vorgabe) → Origin gewinnt; ohne Origin bleibt die Konfiguration', () => {
|
||||
const { service } = make(makeMail(), makeConfig({ TESSERA_APP_URL: 'http://localhost:3000' }));
|
||||
expect(service.resolveAppUrl('https://alpha.example.invalid')).toBe('https://alpha.example.invalid');
|
||||
expect(service.resolveAppUrl(undefined)).toBe('http://localhost:3000');
|
||||
});
|
||||
|
||||
it('ungueltiger Origin wird ignoriert', () => {
|
||||
expect(normalizeOrigin('javascript:alert(1)')).toBeNull();
|
||||
expect(normalizeOrigin('kein origin')).toBeNull();
|
||||
expect(normalizeOrigin('https://a.example.invalid/pfad')).toBe('https://a.example.invalid');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
BadGatewayException,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { User } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { WELCOME_TOKEN_TTL_MS } from '../auth/password-reset-token';
|
||||
import { loadWelcomeHeaderPng, MailService, WELCOME_HEADER_CID } from '../mail/mail.service';
|
||||
import { renderWelcomeMail, type WelcomeMailAccount } from '../mail/welcome-mail.template';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* WelcomeMailService — Willkommensmail aus der Benutzerverwaltung
|
||||
* (Administrator → Benutzer, "Willkommensmail senden").
|
||||
*
|
||||
* Kann an JEDEN Benutzer gehen, unabhaengig davon, ob und wann er sich
|
||||
* schon angemeldet hat (Wunsch des Betreibers: auch zum Erneuern des
|
||||
* Zugangs und zum Ausprobieren). Wer wem senden darf (nur eigener
|
||||
* Mandant, ADMIN nie an SUPER_ADMIN), prueft der Controller wie beim
|
||||
* Bearbeiten. Erneutes Senden ist erlaubt und ueberschreibt
|
||||
* `welcomeMailSentAt`. Abgelehnt wird nur ein deaktiviertes Konto (409),
|
||||
* ein Konto ohne Adresse (400) und ein Mandant ohne Versandweg (409).
|
||||
*
|
||||
* Anmeldehinweis je Kontoart:
|
||||
* - verzeichnisgefuehrt (`ldapDn` gesetzt): Hinweis auf das gewohnte
|
||||
* Windows-Passwort — Tessera kennt und setzt dieses Kennwort nicht (das
|
||||
* Verzeichnis bleibt nur lesend angebunden);
|
||||
* - lokal: Link "Passwort festlegen" mit einem frischen `PasswordResetToken`
|
||||
* — derselbe Token, dieselbe Seite `/reset-password/<token>` und dieselbe
|
||||
* Frist `WELCOME_TOKEN_TTL_MS` (7 Tage statt 1 Stunde wie beim Weg "Passwort
|
||||
* vergessen"). Ein Kennwort steht NIE in der Mail.
|
||||
*
|
||||
* Mandantenbindung: der Aufrufer (`UserController`) hat den Zielbenutzer
|
||||
* bereits rollenabhaengig aufgeloest und die Mandanten-/Rollenregeln
|
||||
* geprueft; beide Zugriffe hier (`passwordResetToken.create`,
|
||||
* `user.update`) binden ueber `forTenant()` an den Mandanten des ZIELS —
|
||||
* dieselbe Regel wie `UserController.update`. Der Versand laeuft ueber den
|
||||
* SMTP-Weg genau dieses Mandanten (`MailService.sendWelcomeMail`).
|
||||
*
|
||||
* Adresse in der Mail: `TESSERA_APP_URL` (in Compose aus `APP_URL`), sonst
|
||||
* der Origin der Admin-Anfrage. Zeigt die Konfiguration nur auf
|
||||
* `localhost` (Compose-Vorgabe, nie gesetzt), gewinnt ein vorhandener
|
||||
* Origin — sonst bekaeme der Empfaenger einen Link, der auf seinem
|
||||
* Rechner ins Leere fuehrt.
|
||||
*/
|
||||
|
||||
/** Zielbenutzer, so weit dieser Dienst ihn liest. */
|
||||
export type WelcomeMailTarget = Pick<
|
||||
User,
|
||||
| 'id'
|
||||
| 'tenantId'
|
||||
| 'username'
|
||||
| 'displayName'
|
||||
| 'email'
|
||||
| 'ldapDn'
|
||||
| 'isActive'
|
||||
>;
|
||||
|
||||
export interface WelcomeMailResult {
|
||||
to: string;
|
||||
welcomeMailSentAt: Date;
|
||||
}
|
||||
|
||||
const FALLBACK_APP_URL = 'http://localhost:3000';
|
||||
|
||||
/** `http(s)://host[:port]` aus einem Origin-Kopf, sonst `null`. */
|
||||
export function normalizeOrigin(value: string | undefined | null): string | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||
return url.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isLocalhostUrl(value: string): boolean {
|
||||
try {
|
||||
const host = new URL(value).hostname;
|
||||
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WelcomeMailService {
|
||||
private readonly logger = new Logger(WelcomeMailService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mailService: MailService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
/** Ob fuer den Mandanten ein Versandweg eingerichtet ist (Knopf aktiv/inaktiv). */
|
||||
async isAvailable(tenantId: string): Promise<boolean> {
|
||||
return this.mailService.hasConfiguredTransport(tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Oeffentliche Adresse fuer Links in der Mail: Konfiguration, sonst Origin
|
||||
* der Anfrage, sonst die Entwicklungsvorgabe (siehe Kopfkommentar).
|
||||
*/
|
||||
resolveAppUrl(requestOrigin?: string | null): string {
|
||||
const raw = (this.configService.get<string>('TESSERA_APP_URL') ?? '').trim();
|
||||
const configured = /^https?:\/\//i.test(raw) ? raw.replace(/\/+$/, '') : '';
|
||||
const origin = normalizeOrigin(requestOrigin);
|
||||
if (configured && !(isLocalhostUrl(configured) && origin)) {
|
||||
return configured;
|
||||
}
|
||||
return origin ?? (configured || FALLBACK_APP_URL);
|
||||
}
|
||||
|
||||
async send(target: WelcomeMailTarget, requestOrigin?: string | null): Promise<WelcomeMailResult> {
|
||||
if (!target.isActive) {
|
||||
throw new ConflictException(
|
||||
'Dieses Konto ist deaktiviert. Aktivieren Sie es zuerst, bevor Sie eine Willkommensmail senden.',
|
||||
);
|
||||
}
|
||||
const to = target.email?.trim();
|
||||
if (!to) {
|
||||
throw new BadRequestException('Für diesen Benutzer ist keine E-Mail-Adresse hinterlegt.');
|
||||
}
|
||||
if (!(await this.mailService.hasConfiguredTransport(target.tenantId))) {
|
||||
throw new ConflictException(
|
||||
'Für den E-Mail-Versand ist noch kein SMTP-Server eingerichtet. Ein Administrator legt ihn unter Administrator → SMTP fest.',
|
||||
);
|
||||
}
|
||||
|
||||
const appUrl = this.resolveAppUrl(requestOrigin);
|
||||
const tenantPrisma = forTenant(this.prisma, target.tenantId);
|
||||
|
||||
let account: WelcomeMailAccount;
|
||||
if (target.ldapDn) {
|
||||
account = { kind: 'directory' };
|
||||
} else {
|
||||
// Derselbe Token wie beim Weg "Passwort vergessen" (T-02-13): zufaellig,
|
||||
// einmal verwendbar, gleiche Frist.
|
||||
const token = randomUUID();
|
||||
await tenantPrisma.passwordResetToken.create({
|
||||
data: {
|
||||
token,
|
||||
userId: target.id,
|
||||
expiresAt: new Date(Date.now() + WELCOME_TOKEN_TTL_MS),
|
||||
},
|
||||
});
|
||||
account = {
|
||||
kind: 'local',
|
||||
setPasswordUrl: `${appUrl}/reset-password/${token}`,
|
||||
validHours: Math.round(WELCOME_TOKEN_TTL_MS / (60 * 60 * 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
const mail = renderWelcomeMail({
|
||||
name: target.displayName?.trim() || target.username,
|
||||
username: target.username,
|
||||
appUrl,
|
||||
account,
|
||||
headerImageSrc: loadWelcomeHeaderPng() ? `cid:${WELCOME_HEADER_CID}` : null,
|
||||
});
|
||||
|
||||
try {
|
||||
await this.mailService.sendWelcomeMail(target.tenantId, to, mail);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Welcome mail to user ${target.id} failed`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
throw new BadGatewayException(
|
||||
'Die Willkommensmail konnte nicht gesendet werden. Bitte prüfen Sie die SMTP-Einstellungen oder versuchen Sie es später erneut.',
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await tenantPrisma.user.update({
|
||||
where: { id: target.id },
|
||||
data: { welcomeMailSentAt: new Date() },
|
||||
select: { welcomeMailSentAt: true },
|
||||
});
|
||||
const welcomeMailSentAt = updated.welcomeMailSentAt ?? new Date();
|
||||
|
||||
this.logger.log(`Welcome mail sent to user ${target.id} (tenant ${target.tenantId})`);
|
||||
return { to, welcomeMailSentAt };
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { UserAccessModal } from './components/UserAccessModal';
|
||||
|
||||
@@ -19,6 +19,10 @@ interface User {
|
||||
isActive: boolean;
|
||||
tenantId: string;
|
||||
createdAt: string;
|
||||
/** `null` = hat sich noch nie angemeldet (Spalte "Letzte Anmeldung": "Noch nie"). */
|
||||
lastLoginAt?: string | null;
|
||||
/** Zeitpunkt der letzten Willkommensmail, `null` = nie gesendet. */
|
||||
welcomeMailSentAt?: string | null;
|
||||
}
|
||||
|
||||
interface UserFormData {
|
||||
@@ -57,6 +61,8 @@ export default function AdminUsersPage() {
|
||||
const t = useTranslations('admin.users');
|
||||
const tCommon = useTranslations('common');
|
||||
const tHeader = useTranslations('header');
|
||||
const tWelcome = useTranslations('admin.users.welcomeMail');
|
||||
const locale = useLocale();
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
@@ -68,6 +74,13 @@ export default function AdminUsersPage() {
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [detailsUser, setDetailsUser] = useState<User | null>(null);
|
||||
// Willkommensmail: Versandweg vorhanden? `null` = noch unbekannt (Knopf
|
||||
// bleibt bedienbar, die API meldet einen fehlenden Versandweg selbst).
|
||||
const [welcomeAvailable, setWelcomeAvailable] = useState<boolean | null>(null);
|
||||
const [welcomeUser, setWelcomeUser] = useState<User | null>(null);
|
||||
const [welcomeSending, setWelcomeSending] = useState(false);
|
||||
const [welcomeError, setWelcomeError] = useState<string | null>(null);
|
||||
const [welcomeSuccess, setWelcomeSuccess] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState<UserFormData>({
|
||||
username: '',
|
||||
email: '',
|
||||
@@ -77,8 +90,7 @@ export default function AdminUsersPage() {
|
||||
});
|
||||
|
||||
// Access check: only ADMIN and SUPER_ADMIN
|
||||
const hasAccess =
|
||||
currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
|
||||
const hasAccess = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoadError(null);
|
||||
@@ -106,6 +118,73 @@ export default function AdminUsersPage() {
|
||||
}
|
||||
}, [hasAccess, fetchUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAccess) return;
|
||||
let cancelled = false;
|
||||
fetch(`${API_URL}/users/welcome-mail/status`, { credentials: 'include' })
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return;
|
||||
const body = (await res.json()) as { available?: unknown };
|
||||
if (!cancelled && typeof body?.available === 'boolean') {
|
||||
setWelcomeAvailable(body.available);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* unbekannt lassen — die API prueft beim Versand selbst */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hasAccess]);
|
||||
|
||||
const formatDateTime = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(d);
|
||||
};
|
||||
|
||||
/**
|
||||
* Grund, warum der Knopf "Willkommensmail senden" nicht bedienbar ist,
|
||||
* oder `null`. Rein ergonomisch — die API prueft dasselbe (400/409).
|
||||
*/
|
||||
const welcomeBlockedReason = (user: User): string | null => {
|
||||
if (!user.email) return tWelcome('noEmail');
|
||||
if (!user.isActive) return tWelcome('inactive');
|
||||
if (welcomeAvailable === false) return tWelcome('noSmtp');
|
||||
return null;
|
||||
};
|
||||
|
||||
const openWelcome = (user: User) => {
|
||||
setWelcomeUser(user);
|
||||
setWelcomeError(null);
|
||||
setWelcomeSuccess(null);
|
||||
};
|
||||
|
||||
const handleSendWelcome = async () => {
|
||||
if (!welcomeUser) return;
|
||||
setWelcomeSending(true);
|
||||
setWelcomeError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/users/${welcomeUser.id}/welcome-mail`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (res.ok) {
|
||||
const email = welcomeUser.email ?? '';
|
||||
setWelcomeUser(null);
|
||||
setWelcomeSuccess(tWelcome('success', { email }));
|
||||
fetchUsers();
|
||||
} else {
|
||||
const detail = await readApiMessage(res);
|
||||
setWelcomeError(detail ? t('errors.serverRejected', { detail }) : t('errors.generic'));
|
||||
}
|
||||
} catch {
|
||||
setWelcomeError(t('errors.network'));
|
||||
} finally {
|
||||
setWelcomeSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingUser(null);
|
||||
setFormData({
|
||||
@@ -136,9 +215,7 @@ export default function AdminUsersPage() {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
const url = editingUser
|
||||
? `${API_URL}/users/${editingUser.id}`
|
||||
: `${API_URL}/users`;
|
||||
const url = editingUser ? `${API_URL}/users/${editingUser.id}` : `${API_URL}/users`;
|
||||
const method = editingUser ? 'PATCH' : 'POST';
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
@@ -166,9 +243,7 @@ export default function AdminUsersPage() {
|
||||
fetchUsers();
|
||||
} else {
|
||||
const detail = await readApiMessage(res);
|
||||
setFormError(
|
||||
detail ? t('errors.serverRejected', { detail }) : t('errors.generic'),
|
||||
);
|
||||
setFormError(detail ? t('errors.serverRejected', { detail }) : t('errors.generic'));
|
||||
}
|
||||
} catch {
|
||||
setFormError(t('errors.network'));
|
||||
@@ -187,9 +262,7 @@ export default function AdminUsersPage() {
|
||||
fetchUsers();
|
||||
} else {
|
||||
const detail = await readApiMessage(res);
|
||||
setDeleteError(
|
||||
detail ? t('errors.serverRejected', { detail }) : t('errors.generic'),
|
||||
);
|
||||
setDeleteError(detail ? t('errors.serverRejected', { detail }) : t('errors.generic'));
|
||||
}
|
||||
} catch {
|
||||
setDeleteError(t('errors.network'));
|
||||
@@ -212,6 +285,13 @@ export default function AdminUsersPage() {
|
||||
const canManageRow = (user: User) =>
|
||||
!(user.role === 'SUPER_ADMIN' && currentUser?.role !== 'SUPER_ADMIN');
|
||||
|
||||
/**
|
||||
* Willkommensmail: an jeden Benutzer, dessen Zeile die angemeldete Person
|
||||
* verwalten darf (Zielrollen-Riegel wie beim Bearbeiten). Spiegelt die
|
||||
* API, die allein entscheidet.
|
||||
*/
|
||||
const canSendWelcome = (user: User) => canManageRow(user);
|
||||
|
||||
const roleBadgeClass = (role: string) => {
|
||||
switch (role) {
|
||||
case 'SUPER_ADMIN':
|
||||
@@ -228,11 +308,7 @@ export default function AdminUsersPage() {
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreate}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<button type="button" onClick={openCreate} className="btn btn-primary">
|
||||
{t('create')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -246,6 +322,23 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{welcomeSuccess && (
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-start justify-between gap-3 rounded-md border border-status-ok/40 bg-status-ok/12 p-3 text-sm text-status-ok-fg"
|
||||
>
|
||||
<span>{welcomeSuccess}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWelcomeSuccess(null)}
|
||||
aria-label={tCommon('close')}
|
||||
className="rounded px-1 text-xs hover:bg-muted"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users table */}
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground">{tCommon('loading')}</p>
|
||||
@@ -271,6 +364,9 @@ export default function AdminUsersPage() {
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||
{t('status')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||
{t('lastLogin')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
|
||||
{t('actions')}
|
||||
</th>
|
||||
@@ -279,15 +375,9 @@ export default function AdminUsersPage() {
|
||||
<tbody className="divide-y divide-border">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-3 font-medium text-foreground">
|
||||
{user.username}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{user.email ?? '–'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{user.displayName ?? '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium text-foreground">{user.username}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{user.email ?? '–'}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{user.displayName ?? '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(user.role)}`}
|
||||
@@ -305,23 +395,52 @@ export default function AdminUsersPage() {
|
||||
>
|
||||
{user.isActive ? tCommon('active') : tCommon('inactive')}
|
||||
</span>
|
||||
{user.welcomeMailSentAt && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{tWelcome('sentAt', { date: formatDateTime(user.welcomeMailSentAt) })}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">
|
||||
{user.lastLoginAt ? formatDateTime(user.lastLoginAt) : t('neverLoggedIn')}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
{canSendWelcome(user) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openWelcome(user)}
|
||||
disabled={welcomeBlockedReason(user) !== null}
|
||||
title={
|
||||
welcomeBlockedReason(user) ??
|
||||
(user.welcomeMailSentAt ? tWelcome('resend') : tWelcome('button'))
|
||||
}
|
||||
className="rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ActionIcon kind="mail" />
|
||||
<span className="sr-only">
|
||||
{user.welcomeMailSentAt ? tWelcome('resend') : tWelcome('button')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailsUser(user)}
|
||||
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
|
||||
title={t('grants.detailsButton')}
|
||||
className="rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
{t('grants.detailsButton')}
|
||||
<ActionIcon kind="details" />
|
||||
<span className="sr-only">{t('grants.detailsButton')}</span>
|
||||
</button>
|
||||
{canManageRow(user) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(user)}
|
||||
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
|
||||
title={tCommon('edit')}
|
||||
className="rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
{tCommon('edit')}
|
||||
<ActionIcon kind="edit" />
|
||||
<span className="sr-only">{tCommon('edit')}</span>
|
||||
</button>
|
||||
)}
|
||||
{canManageRow(user) && (
|
||||
@@ -332,9 +451,11 @@ export default function AdminUsersPage() {
|
||||
setDeleteError(null);
|
||||
}}
|
||||
disabled={user.id === currentUser?.id}
|
||||
className="rounded px-2 py-1 text-xs text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-30 disabled:cursor-not-allowed disabled:pointer-events-none"
|
||||
title={tCommon('delete')}
|
||||
className="rounded p-1.5 text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-30 disabled:cursor-not-allowed disabled:pointer-events-none"
|
||||
>
|
||||
{tCommon('delete')}
|
||||
<ActionIcon kind="delete" />
|
||||
<span className="sr-only">{tCommon('delete')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -363,9 +484,7 @@ export default function AdminUsersPage() {
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, username: e.target.value })
|
||||
}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -378,9 +497,7 @@ export default function AdminUsersPage() {
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -388,9 +505,7 @@ export default function AdminUsersPage() {
|
||||
<label htmlFor="user-password" className="text-sm font-medium text-foreground">
|
||||
{t('password')}
|
||||
{editingUser && (
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
(optional)
|
||||
</span>
|
||||
<span className="ml-1 text-xs text-muted-foreground">(optional)</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
@@ -399,9 +514,7 @@ export default function AdminUsersPage() {
|
||||
required={!editingUser}
|
||||
minLength={8}
|
||||
value={formData.password}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, password: e.target.value })
|
||||
}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -413,9 +526,7 @@ export default function AdminUsersPage() {
|
||||
id="user-display-name"
|
||||
type="text"
|
||||
value={formData.displayName}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, displayName: e.target.value })
|
||||
}
|
||||
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -437,9 +548,7 @@ export default function AdminUsersPage() {
|
||||
<option value="USER">{tHeader('role.USER')}</option>
|
||||
<option value="ADMIN">{tHeader('role.ADMIN')}</option>
|
||||
{currentUser?.role === 'SUPER_ADMIN' && (
|
||||
<option value="SUPER_ADMIN">
|
||||
{tHeader('role.SUPER_ADMIN')}
|
||||
</option>
|
||||
<option value="SUPER_ADMIN">{tHeader('role.SUPER_ADMIN')}</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
@@ -459,10 +568,7 @@ export default function AdminUsersPage() {
|
||||
>
|
||||
{tCommon('cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{tCommon('save')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -475,9 +581,7 @@ export default function AdminUsersPage() {
|
||||
{deleteConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-sm rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
|
||||
<p className="text-sm text-foreground mb-4">
|
||||
{t('deleteConfirm')}
|
||||
</p>
|
||||
<p className="text-sm text-foreground mb-4">{t('deleteConfirm')}</p>
|
||||
{deleteError && (
|
||||
<div
|
||||
role="alert"
|
||||
@@ -506,6 +610,57 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Willkommensmail: Bestaetigung mit Empfaengeradresse */}
|
||||
{welcomeUser && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="welcome-mail-title"
|
||||
className="w-full max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg"
|
||||
>
|
||||
<h2 id="welcome-mail-title" className="text-lg font-bold text-foreground mb-3">
|
||||
{tWelcome('dialogTitle')}
|
||||
</h2>
|
||||
<p className="text-sm text-foreground">{tWelcome('dialogText')}</p>
|
||||
<p className="mt-2 mb-4 rounded-md bg-muted px-3 py-2 text-sm font-medium text-foreground break-all">
|
||||
{welcomeUser.displayName ?? welcomeUser.username} <{welcomeUser.email}>
|
||||
</p>
|
||||
{welcomeUser.welcomeMailSentAt && (
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
{tWelcome('dialogResend', { date: formatDateTime(welcomeUser.welcomeMailSentAt) })}
|
||||
</p>
|
||||
)}
|
||||
{welcomeError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive mb-4"
|
||||
>
|
||||
{welcomeError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWelcomeUser(null)}
|
||||
disabled={welcomeSending}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{tCommon('cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendWelcome}
|
||||
disabled={welcomeSending}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{welcomeSending ? tWelcome('sending') : tWelcome('send')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User-Detail-Zugriff (D-16): geerbte + direkte Modulfreigaben */}
|
||||
{detailsUser && (
|
||||
<UserAccessModal
|
||||
@@ -517,3 +672,52 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Symbole der Zeilenaktionen (quick-260930): die Tabelle wurde mit der
|
||||
* Spalte "Letzte Anmeldung" und der Willkommensmail zu breit fuer Text-
|
||||
* Knoepfe. Beschriftung steht an jedem Knopf als unsichtbarer Text (sr-only)
|
||||
* und als title (Tooltip).
|
||||
*/
|
||||
function ActionIcon({ kind }: { kind: 'mail' | 'details' | 'edit' | 'delete' }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{kind === 'mail' && (
|
||||
<>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<path d="m3 7 9 6 9-6" />
|
||||
</>
|
||||
)}
|
||||
{kind === 'details' && (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 11v5" />
|
||||
<path d="M12 8h.01" />
|
||||
</>
|
||||
)}
|
||||
{kind === 'edit' && (
|
||||
<>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</>
|
||||
)}
|
||||
{kind === 'delete' && (
|
||||
<>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4h8v2" />
|
||||
<path d="M19 6l-1 14H6L5 6" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ const messages: Record<string, Record<string, unknown>> = {
|
||||
displayName: 'Anzeigename',
|
||||
role: 'Rolle',
|
||||
status: 'Status',
|
||||
lastLogin: 'Letzte Anmeldung',
|
||||
neverLoggedIn: 'Noch nie',
|
||||
actions: 'Aktionen',
|
||||
password: 'Passwort',
|
||||
noUsers: 'Keine Benutzer gefunden',
|
||||
@@ -31,6 +33,20 @@ const messages: Record<string, Record<string, unknown>> = {
|
||||
loadFailed: 'Die Benutzerliste konnte nicht geladen werden. Bitte laden Sie die Seite neu.',
|
||||
},
|
||||
},
|
||||
'admin.users.welcomeMail': {
|
||||
button: 'Willkommensmail senden',
|
||||
resend: 'Erneut senden',
|
||||
sentAt: 'Willkommensmail gesendet am {date}',
|
||||
noEmail: 'Keine E-Mail-Adresse hinterlegt – bitte zuerst eine Adresse eintragen.',
|
||||
noSmtp: 'Es ist noch kein SMTP-Server eingerichtet (Administrator → SMTP).',
|
||||
inactive: 'Das Konto ist deaktiviert.',
|
||||
dialogTitle: 'Willkommensmail senden',
|
||||
dialogText: 'Tessera schickt eine Willkommensmail an:',
|
||||
dialogResend: 'Eine Willkommensmail wurde bereits am {date} gesendet. Sie wird erneut verschickt.',
|
||||
send: 'Senden',
|
||||
sending: 'Wird gesendet …',
|
||||
success: 'Die Willkommensmail an {email} wurde gesendet.',
|
||||
},
|
||||
header: {
|
||||
role: {
|
||||
SUPER_ADMIN: 'Super-Admin',
|
||||
@@ -47,6 +63,7 @@ const messages: Record<string, Record<string, unknown>> = {
|
||||
accessDenied: 'Zugriff verweigert',
|
||||
active: 'Aktiv',
|
||||
inactive: 'Inaktiv',
|
||||
close: 'Schließen',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -69,6 +86,7 @@ function resolve(ns: string, key: string, params?: Record<string, unknown>): str
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: (ns: string) => (key: string, params?: Record<string, unknown>) =>
|
||||
resolve(ns, key, params),
|
||||
useLocale: () => 'de',
|
||||
}));
|
||||
|
||||
const mockAuthStore = vi.fn();
|
||||
@@ -93,6 +111,8 @@ interface User {
|
||||
isActive: boolean;
|
||||
tenantId: string;
|
||||
createdAt: string;
|
||||
lastLoginAt?: string | null;
|
||||
welcomeMailSentAt?: string | null;
|
||||
}
|
||||
|
||||
const mockUsers: User[] = [
|
||||
@@ -524,13 +544,13 @@ describe('AdminUsersPage — Aktionsknoepfe der SUPER_ADMIN-Zeile (WINDOWS #36,
|
||||
});
|
||||
|
||||
const superAdminRow = screen.getByText('super.null').closest('tr') as HTMLElement;
|
||||
expect(within(superAdminRow).getByText('Details')).toBeInTheDocument();
|
||||
expect(within(superAdminRow).getByRole('button', { name: 'Details' })).toBeInTheDocument();
|
||||
expect(within(superAdminRow).queryByText('Bearbeiten')).not.toBeInTheDocument();
|
||||
expect(within(superAdminRow).queryByText('Löschen')).not.toBeInTheDocument();
|
||||
|
||||
const normalRow = screen.getByText('user.zwei').closest('tr') as HTMLElement;
|
||||
expect(within(normalRow).getByText('Bearbeiten')).toBeInTheDocument();
|
||||
expect(within(normalRow).getByText('Löschen')).toBeInTheDocument();
|
||||
expect(within(normalRow).getByRole('button', { name: 'Bearbeiten' })).toBeInTheDocument();
|
||||
expect(within(normalRow).getByRole('button', { name: 'Löschen' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('bietet einem SUPER_ADMIN in der SUPER_ADMIN-Zeile beide Aktionsknoepfe an', async () => {
|
||||
@@ -544,8 +564,8 @@ describe('AdminUsersPage — Aktionsknoepfe der SUPER_ADMIN-Zeile (WINDOWS #36,
|
||||
});
|
||||
|
||||
const superAdminRow = screen.getByText('super.null').closest('tr') as HTMLElement;
|
||||
expect(within(superAdminRow).getByText('Bearbeiten')).toBeInTheDocument();
|
||||
expect(within(superAdminRow).getByText('Löschen')).toBeInTheDocument();
|
||||
expect(within(superAdminRow).getByRole('button', { name: 'Bearbeiten' })).toBeInTheDocument();
|
||||
expect(within(superAdminRow).getByRole('button', { name: 'Löschen' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('haelt die bestehende Sperre gegen Selbstloeschung unveraendert (ADMIN u1)', async () => {
|
||||
@@ -559,8 +579,165 @@ describe('AdminUsersPage — Aktionsknoepfe der SUPER_ADMIN-Zeile (WINDOWS #36,
|
||||
});
|
||||
|
||||
const ownRow = screen.getByText('admin.eins').closest('tr') as HTMLElement;
|
||||
const ownDeleteButton = within(ownRow).getByText('Löschen');
|
||||
const ownDeleteButton = within(ownRow).getByRole('button', { name: 'Löschen' });
|
||||
expect(ownDeleteButton).toBeInTheDocument();
|
||||
expect(ownDeleteButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminUsersPage — Willkommensmail', () => {
|
||||
const base = {
|
||||
role: 'USER' as const,
|
||||
isActive: true,
|
||||
tenantId: 't1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
welcomeMailSentAt: null,
|
||||
};
|
||||
const welcomeUsers: User[] = [
|
||||
{ ...base, id: 'u1', username: 'admin.eins', email: 'admin.eins@ctl.de', displayName: 'Admin Eins', role: 'ADMIN', lastLoginAt: '2026-09-01T08:00:00.000Z' },
|
||||
{ ...base, id: 'u2', username: 'neu.zwei', email: 'neu.zwei@ctl.de', displayName: 'Neu Zwei', lastLoginAt: null },
|
||||
{ ...base, id: 'u3', username: 'ohne.adresse', email: null, displayName: null, lastLoginAt: null },
|
||||
{ ...base, id: 'u4', username: 'schon.da', email: 'schon.da@ctl.de', displayName: null, lastLoginAt: '2026-09-02T08:00:00.000Z' },
|
||||
{ ...base, id: 'u5', username: 'super.fuenf', email: 'super@ctl.de', displayName: null, role: 'SUPER_ADMIN', lastLoginAt: '2026-09-02T08:00:00.000Z' },
|
||||
];
|
||||
|
||||
function stubFetch(opts: { available?: boolean; post?: () => Promise<unknown> } = {}) {
|
||||
const fetchMock = vi.fn((url: string, init?: RequestInit) => {
|
||||
if (url.endsWith('/users/welcome-mail/status')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ available: opts.available ?? true }) });
|
||||
}
|
||||
if (url.endsWith('/users') && (!init || init.method === undefined)) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(welcomeUsers) });
|
||||
}
|
||||
if (url.endsWith('/welcome-mail') && init?.method === 'POST') {
|
||||
return (opts.post ?? (() => Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) })))();
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
it('Knopf bei jedem verwaltbaren Benutzer, auch nach einer Anmeldung; SUPER_ADMIN-Konto fuer einen ADMIN nie; ohne Adresse deaktiviert', async () => {
|
||||
stubAdmin();
|
||||
stubFetch();
|
||||
render(<AdminUsersPage />);
|
||||
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
|
||||
|
||||
const rowAdmin = screen.getByText('admin.eins').closest('tr') as HTMLElement;
|
||||
expect(within(rowAdmin).getByRole('button', { name: 'Willkommensmail senden' })).toBeEnabled();
|
||||
|
||||
const rowDa = screen.getByText('schon.da').closest('tr') as HTMLElement;
|
||||
expect(within(rowDa).getByRole('button', { name: 'Willkommensmail senden' })).toBeEnabled();
|
||||
|
||||
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
|
||||
expect(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' })).toBeEnabled();
|
||||
|
||||
// SUPER_ADMIN-Konto: fuer einen ADMIN gesperrt wie beim Bearbeiten
|
||||
const rowSuper = screen.getByText('super.fuenf').closest('tr') as HTMLElement;
|
||||
expect(within(rowSuper).queryByText('Willkommensmail senden')).not.toBeInTheDocument();
|
||||
|
||||
const rowOhne = screen.getByText('ohne.adresse').closest('tr') as HTMLElement;
|
||||
const btn = within(rowOhne).getByRole('button', { name: 'Willkommensmail senden' });
|
||||
expect(btn).toBeDisabled();
|
||||
expect(btn).toHaveAttribute('title', 'Keine E-Mail-Adresse hinterlegt – bitte zuerst eine Adresse eintragen.');
|
||||
});
|
||||
|
||||
it('Spalte "Letzte Anmeldung": Datum/Uhrzeit lokal formatiert, sonst "Noch nie"', async () => {
|
||||
stubAdmin();
|
||||
stubFetch();
|
||||
render(<AdminUsersPage />);
|
||||
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
|
||||
expect(screen.getByText('Letzte Anmeldung')).toBeInTheDocument();
|
||||
|
||||
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
|
||||
expect(within(rowNeu).getByText('Noch nie')).toBeInTheDocument();
|
||||
|
||||
const expected = new Intl.DateTimeFormat('de', { dateStyle: 'medium', timeStyle: 'short' }).format(
|
||||
new Date('2026-09-01T08:00:00.000Z'),
|
||||
);
|
||||
const rowAdmin = screen.getByText('admin.eins').closest('tr') as HTMLElement;
|
||||
expect(within(rowAdmin).getByText(expected)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ohne eingerichtetes SMTP ist der Knopf deaktiviert und nennt den Grund', async () => {
|
||||
stubAdmin();
|
||||
stubFetch({ available: false });
|
||||
render(<AdminUsersPage />);
|
||||
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
|
||||
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
|
||||
await waitFor(() => expect(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' })).toBeDisabled());
|
||||
expect(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' })).toHaveAttribute(
|
||||
'title',
|
||||
'Es ist noch kein SMTP-Server eingerichtet (Administrator → SMTP).',
|
||||
);
|
||||
});
|
||||
|
||||
it('Dialog nennt den Empfaenger; Senden ruft POST /users/:id/welcome-mail und zeigt die Erfolgsmeldung', async () => {
|
||||
stubAdmin();
|
||||
const fetchMock = stubFetch();
|
||||
render(<AdminUsersPage />);
|
||||
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
|
||||
|
||||
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
|
||||
await userEvent.click(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(within(dialog).getByText(/neu\.zwei@ctl\.de/)).toBeInTheDocument();
|
||||
await userEvent.click(within(dialog).getByText('Senden'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Die Willkommensmail an neu.zwei@ctl.de wurde gesendet.')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
const post = fetchMock.mock.calls.find((c) => (c[1] as RequestInit | undefined)?.method === 'POST');
|
||||
expect(post?.[0]).toMatch(/\/users\/u2\/welcome-mail$/);
|
||||
});
|
||||
|
||||
it('bei einer Ablehnung (409) bleibt der Dialog offen und zeigt den Servertext', async () => {
|
||||
stubAdmin();
|
||||
stubFetch({
|
||||
post: () =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 409,
|
||||
json: () => Promise.resolve({ message: 'Dieser Benutzer hat sich bereits angemeldet.' }),
|
||||
}),
|
||||
});
|
||||
render(<AdminUsersPage />);
|
||||
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
|
||||
|
||||
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
|
||||
await userEvent.click(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' }));
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
await userEvent.click(within(dialog).getByText('Senden'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Der Server hat die Aktion abgelehnt: Dieser Benutzer hat sich bereits angemeldet.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('nach einem Versand heisst der Knopf "Erneut senden" und die Zeile nennt das Datum', async () => {
|
||||
stubAdmin();
|
||||
welcomeUsers[1] = { ...welcomeUsers[1], welcomeMailSentAt: '2026-09-29T10:15:00.000Z' };
|
||||
stubFetch();
|
||||
render(<AdminUsersPage />);
|
||||
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
|
||||
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
|
||||
expect(within(rowNeu).getByRole('button', { name: 'Erneut senden' })).toBeInTheDocument();
|
||||
expect(within(rowNeu).getByText(/Willkommensmail gesendet am/)).toBeInTheDocument();
|
||||
welcomeUsers[1] = { ...welcomeUsers[1], welcomeMailSentAt: null };
|
||||
});
|
||||
|
||||
it('SUPER_ADMIN sieht den Knopf auch bei einem angemeldeten SUPER_ADMIN-Konto', async () => {
|
||||
stubAdmin('u9', 'SUPER_ADMIN');
|
||||
stubFetch();
|
||||
render(<AdminUsersPage />);
|
||||
await waitFor(() => expect(screen.getByText('super.fuenf')).toBeInTheDocument());
|
||||
const rowSuper = screen.getByText('super.fuenf').closest('tr') as HTMLElement;
|
||||
expect(within(rowSuper).getByRole('button', { name: 'Willkommensmail senden' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -535,6 +535,8 @@
|
||||
"displayName": "Anzeigename",
|
||||
"role": "Rolle",
|
||||
"status": "Status",
|
||||
"lastLogin": "Letzte Anmeldung",
|
||||
"neverLoggedIn": "Noch nie",
|
||||
"actions": "Aktionen",
|
||||
"password": "Passwort",
|
||||
"noUsers": "Keine Benutzer gefunden",
|
||||
@@ -557,6 +559,20 @@
|
||||
"generic": "Die Aktion konnte nicht durchgeführt werden. Bitte erneut versuchen.",
|
||||
"network": "Der Server ist nicht erreichbar. Bitte erneut versuchen.",
|
||||
"loadFailed": "Die Benutzerliste konnte nicht geladen werden. Bitte laden Sie die Seite neu."
|
||||
},
|
||||
"welcomeMail": {
|
||||
"button": "Willkommensmail senden",
|
||||
"resend": "Erneut senden",
|
||||
"sentAt": "Willkommensmail gesendet am {date}",
|
||||
"noEmail": "Keine E-Mail-Adresse hinterlegt – bitte zuerst eine Adresse eintragen.",
|
||||
"noSmtp": "Es ist noch kein SMTP-Server eingerichtet (Administrator → SMTP).",
|
||||
"inactive": "Das Konto ist deaktiviert.",
|
||||
"dialogTitle": "Willkommensmail senden",
|
||||
"dialogText": "Tessera schickt eine Willkommensmail mit Adresse, Benutzername und Hinweisen zur ersten Anmeldung an:",
|
||||
"dialogResend": "Eine Willkommensmail wurde bereits am {date} gesendet. Sie wird erneut verschickt.",
|
||||
"send": "Senden",
|
||||
"sending": "Wird gesendet …",
|
||||
"success": "Die Willkommensmail an {email} wurde gesendet."
|
||||
}
|
||||
},
|
||||
"tenants": {
|
||||
|
||||
@@ -535,6 +535,8 @@
|
||||
"displayName": "Display Name",
|
||||
"role": "Role",
|
||||
"status": "Status",
|
||||
"lastLogin": "Last sign-in",
|
||||
"neverLoggedIn": "Never",
|
||||
"actions": "Actions",
|
||||
"password": "Password",
|
||||
"noUsers": "No users found",
|
||||
@@ -557,6 +559,20 @@
|
||||
"generic": "The action could not be completed. Please try again.",
|
||||
"network": "The server is not reachable. Please try again.",
|
||||
"loadFailed": "The user list could not be loaded. Please reload the page."
|
||||
},
|
||||
"welcomeMail": {
|
||||
"button": "Send welcome email",
|
||||
"resend": "Send again",
|
||||
"sentAt": "Welcome email sent on {date}",
|
||||
"noEmail": "No email address on file – please add one first.",
|
||||
"noSmtp": "No SMTP server has been set up yet (Administrator → SMTP).",
|
||||
"inactive": "The account is deactivated.",
|
||||
"dialogTitle": "Send welcome email",
|
||||
"dialogText": "Tessera will send a welcome email with the address, username and first sign-in instructions to:",
|
||||
"dialogResend": "A welcome email was already sent on {date}. It will be sent again.",
|
||||
"send": "Send",
|
||||
"sending": "Sending …",
|
||||
"success": "The welcome email to {email} has been sent."
|
||||
}
|
||||
},
|
||||
"tenants": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment node
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
import { SignJWT } from 'jose';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { middleware } from './middleware';
|
||||
|
||||
@@ -67,7 +68,9 @@ describe('middleware — Desktop-Client-Cookie (260917-h2s)', () => {
|
||||
});
|
||||
|
||||
it('Test 6 (quick-260918-gza): /login mit dv/dc/dos setzt zusaetzlich tessera_desktop_client', async () => {
|
||||
const req = new NextRequest('http://localhost:3000/login?desktop=1&dv=1.2.0&dc=a6d1a64&dos=windows');
|
||||
const req = new NextRequest(
|
||||
'http://localhost:3000/login?desktop=1&dv=1.2.0&dc=a6d1a64&dos=windows',
|
||||
);
|
||||
const res = await middleware(req);
|
||||
expect(res.cookies.get('tessera_desktop')?.value).toBe('1');
|
||||
expect(res.cookies.get('tessera_desktop_client')?.value).toBe('1.2.0|a6d1a64|windows');
|
||||
@@ -98,15 +101,23 @@ describe('middleware — Desktop-Client-Cookie (260917-h2s)', () => {
|
||||
);
|
||||
expect((await middleware(spaceInOs)).cookies.get('tessera_desktop_client')).toBeUndefined();
|
||||
|
||||
const missingDv = new NextRequest('http://localhost:3000/login?desktop=1&dc=a6d1a64&dos=windows');
|
||||
const missingDv = new NextRequest(
|
||||
'http://localhost:3000/login?desktop=1&dc=a6d1a64&dos=windows',
|
||||
);
|
||||
expect((await middleware(missingDv)).cookies.get('tessera_desktop_client')).toBeUndefined();
|
||||
|
||||
const emptyCommit = new NextRequest('http://localhost:3000/login?desktop=1&dv=1.2.0&dc=&dos=linux');
|
||||
expect((await middleware(emptyCommit)).cookies.get('tessera_desktop_client')?.value).toBe('1.2.0||linux');
|
||||
const emptyCommit = new NextRequest(
|
||||
'http://localhost:3000/login?desktop=1&dv=1.2.0&dc=&dos=linux',
|
||||
);
|
||||
expect((await middleware(emptyCommit)).cookies.get('tessera_desktop_client')?.value).toBe(
|
||||
'1.2.0||linux',
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 9 (quick-260918-gza, Redirect-Pfad): /dashboard ohne Session setzt beide Cookies auf dem 307', async () => {
|
||||
const req = new NextRequest('http://localhost:3000/dashboard?desktop=1&dv=1.2.0&dc=a6d1a64&dos=linux');
|
||||
const req = new NextRequest(
|
||||
'http://localhost:3000/dashboard?desktop=1&dv=1.2.0&dc=a6d1a64&dos=linux',
|
||||
);
|
||||
const res = await middleware(req);
|
||||
expect(res.status).toBe(307);
|
||||
expect(res.headers.get('location')).toContain('/login');
|
||||
@@ -114,3 +125,65 @@ describe('middleware — Desktop-Client-Cookie (260917-h2s)', () => {
|
||||
expect(res.cookies.get('tessera_desktop_client')?.value).toBe('1.2.0|a6d1a64|linux');
|
||||
});
|
||||
});
|
||||
|
||||
describe('middleware — /login bei bestehender Anmeldung (quick-260930)', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
async function token(claims: Record<string, unknown> = {}) {
|
||||
return new SignJWT({ sub: 'u1', ...claims })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('5m')
|
||||
.sign(new TextEncoder().encode('test-secret'));
|
||||
}
|
||||
|
||||
function loginReq(url: string, session?: string) {
|
||||
return new NextRequest(url, session ? { headers: { cookie: `session=${session}` } } : {});
|
||||
}
|
||||
|
||||
it('angemeldet: /login leitet aufs Dashboard um', async () => {
|
||||
const res = await middleware(loginReq('http://localhost:3000/login', await token()));
|
||||
expect(res.status).toBe(307);
|
||||
expect(new URL(res.headers.get('location') as string).pathname).toBe('/');
|
||||
});
|
||||
|
||||
it('angemeldet mit sicherem next: dorthin', async () => {
|
||||
const res = await middleware(
|
||||
loginReq('http://localhost:3000/login?next=%2Fadmin%2Fusers', await token()),
|
||||
);
|
||||
expect(new URL(res.headers.get('location') as string).pathname).toBe('/admin/users');
|
||||
});
|
||||
|
||||
it('angemeldet mit fremdem oder zirkulaerem next: Dashboard', async () => {
|
||||
for (const next of ['https%3A%2F%2Fboese.example', '%2F%2Fboese.example', '%2Flogin']) {
|
||||
const res = await middleware(
|
||||
loginReq(`http://localhost:3000/login?next=${next}`, await token()),
|
||||
);
|
||||
const loc = new URL(res.headers.get('location') as string);
|
||||
expect(loc.host).toBe('localhost:3000');
|
||||
expect(loc.pathname).toBe('/');
|
||||
}
|
||||
});
|
||||
|
||||
it('ohne oder mit ungueltigem Cookie: Anmeldeseite wie bisher', async () => {
|
||||
const ohne = await middleware(loginReq('http://localhost:3000/login'));
|
||||
expect(ohne.headers.get('x-middleware-next')).toBe('1');
|
||||
const kaputt = await middleware(
|
||||
loginReq('http://localhost:3000/login', 'kein.gueltiges.token'),
|
||||
);
|
||||
expect(kaputt.headers.get('x-middleware-next')).toBe('1');
|
||||
});
|
||||
|
||||
it('Kennwortwechsel ausstehend: keine Umleitung von /login', async () => {
|
||||
const res = await middleware(
|
||||
loginReq('http://localhost:3000/login', await token({ mustChangePassword: true })),
|
||||
);
|
||||
expect(res.headers.get('x-middleware-next')).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { jwtVerify } from 'jose';
|
||||
import { buildNextParam } from '@/lib/safe-next';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { buildNextParam, sanitizeNextPath } from '@/lib/safe-next';
|
||||
|
||||
/**
|
||||
* Next.js middleware for frontend route protection (Pattern 4).
|
||||
@@ -113,6 +113,28 @@ function getSecret() {
|
||||
export async function middleware(req: NextRequest) {
|
||||
const path = req.nextUrl.pathname;
|
||||
|
||||
// Bereits angemeldet und /login aufgerufen (quick-260930, Wunsch des
|
||||
// Nutzers): statt der Anmeldeseite direkt zum Ziel — `next`, sofern ein
|
||||
// sicherer relativer Pfad, sonst das Dashboard. Nur bei gueltiger
|
||||
// Signatur; ist das Konto inzwischen gesperrt, lehnt die API die Sitzung
|
||||
// ab, die Oberflaeche loescht das Cookie serverseitig und schickt zur
|
||||
// Anmeldung zurueck — dann ohne Cookie, also keine Schleife.
|
||||
if (path === '/login' || path.startsWith('/login/')) {
|
||||
const existing = req.cookies.get('session')?.value;
|
||||
if (existing) {
|
||||
try {
|
||||
const { payload } = await jwtVerify(existing, getSecret(), { algorithms: ['HS256'] });
|
||||
if (payload.mustChangePassword !== true) {
|
||||
const next = sanitizeNextPath(req.nextUrl.searchParams.get('next'));
|
||||
const target = next.startsWith('/login') ? '/' : next;
|
||||
return withDesktopCookie(req, NextResponse.redirect(new URL(target, req.nextUrl)));
|
||||
}
|
||||
} catch {
|
||||
// ungueltiges Cookie: Anmeldeseite wie gewohnt zeigen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Allow public routes without authentication
|
||||
if (publicRoutes.some((route) => path.startsWith(route))) {
|
||||
return withDesktopCookie(req, NextResponse.next());
|
||||
@@ -141,10 +163,7 @@ export async function middleware(req: NextRequest) {
|
||||
});
|
||||
|
||||
// D-06: Force password change redirect
|
||||
if (
|
||||
payload.mustChangePassword === true &&
|
||||
!path.startsWith('/change-password')
|
||||
) {
|
||||
if (payload.mustChangePassword === true && !path.startsWith('/change-password')) {
|
||||
return withDesktopCookie(
|
||||
req,
|
||||
NextResponse.redirect(new URL('/change-password', req.nextUrl)),
|
||||
|
||||
@@ -35,7 +35,7 @@ Wichtige Details, die im Code tatsächlich so umgesetzt sind:
|
||||
|
||||
## 2. Benutzerverwaltung
|
||||
|
||||
Der Bereich **Administrator → Benutzer** zeigt eine Tabelle mit Benutzername, E-Mail, Anzeigename, Rolle und Status. Ein ADMIN sieht dabei ausschließlich die Benutzer des eigenen Mandanten, ein SUPER_ADMIN sieht alle.
|
||||
Der Bereich **Administrator → Benutzer** zeigt eine Tabelle mit Benutzername, E-Mail, Anzeigename, Rolle, Status und **Letzte Anmeldung** (Datum und Uhrzeit der letzten Anmeldung, bei neuen Konten „Noch nie“). Ein ADMIN sieht dabei ausschließlich die Benutzer des eigenen Mandanten, ein SUPER_ADMIN sieht alle.
|
||||
|
||||
### Benutzer anlegen
|
||||
|
||||
@@ -53,6 +53,15 @@ Ein Konto, das über die AD-Anbindung importiert oder synchronisiert wurde, hat
|
||||
|
||||
Seit Kurzem ist die E-Mail-Adresse eines Benutzers **optional**: Wenn beim Import eine Adresse bereits einem anderen Konto gehört, wird das Konto trotzdem angelegt bzw. aktualisiert – nur eben ohne diese Adresse. Anmeldung und Zugriff funktionieren für ein solches Konto normal, lediglich Benachrichtigungen per E-Mail (z. B. Passwort-Reset) erreichen es nicht. In der Tabelle wird eine fehlende Adresse als „–“ angezeigt. Bei der manuellen Anlage über das Formular ist eine E-Mail-Adresse weiterhin Pflicht.
|
||||
|
||||
### Willkommensmail
|
||||
|
||||
Bei jedem Benutzer steht in der Tabelle der Button „Willkommensmail senden“ – gedacht vor allem für neue Konten (Spalte „Letzte Anmeldung“: „Noch nie“), er lässt sich aber auch bei Benutzern nutzen, die sich schon angemeldet haben, etwa um die Zugangsdaten erneut zuzuschicken oder die Mail auszuprobieren. Es gelten dieselben Rechte wie beim Bearbeiten: ein ADMIN kann nur Benutzern des eigenen Mandanten und keinem SUPER_ADMIN eine Willkommensmail schicken. Nach einer Rückfrage mit der Empfängeradresse verschickt Tessera eine gestaltete E-Mail mit der Adresse von Tessera, dem Benutzernamen und einem Hinweis zur ersten Anmeldung:
|
||||
|
||||
- **verzeichnisgeführtes Konto (AD/LDAP):** Hinweis, sich mit dem gewohnten Windows-Passwort anzumelden;
|
||||
- **lokales Konto:** ein Button „Passwort festlegen“, über den der Benutzer sein eigenes Passwort setzt. Der Link funktioniert wie „Passwort vergessen?“ – er ist 1 Stunde gültig und nur einmal verwendbar; danach kann der Benutzer auf der Anmeldeseite jederzeit einen neuen anfordern.
|
||||
|
||||
Ein Passwort steht nie in der Mail. Bei einem lokalen Konto, das bereits ein Passwort hat, ersetzt „Passwort festlegen“ dieses Passwort erst, wenn der Benutzer den Link tatsächlich benutzt. Nach dem Versand zeigt die Zeile „Willkommensmail gesendet am …“, und der Button heißt „Erneut senden“. Der Button ist ausgegraut, wenn beim Benutzer keine E-Mail-Adresse hinterlegt ist, das Konto deaktiviert ist oder noch kein Mailserver eingerichtet wurde (siehe Kapitel 6, SMTP); der Grund steht im Hinweistext des Buttons. Die Adresse in der Mail stammt aus der Einstellung `APP_URL` des Servers (siehe `docs/anleitung-betrieb.md`); fehlt sie, nimmt Tessera die Adresse, unter der Sie die Benutzerverwaltung gerade geöffnet haben.
|
||||
|
||||
### Deaktivieren und Löschen
|
||||
|
||||
Die Benutzerliste zeigt einen Status „Aktiv“/„Inaktiv“ an, dieser lässt sich aber **nicht** über einen Schalter im Formular umschalten – im Bearbeiten-Dialog gibt es dafür kein Feld. Ein Konto wird auf zwei Wegen inaktiv:
|
||||
|
||||
@@ -166,10 +166,10 @@ 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) |
|
||||
| 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 |
|
||||
| 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) |
|
||||
| user | 8 | 20 | 0 | **Willkommensmail:** +2 gebunden in `welcome-mail.service.ts` (`passwordResetToken.create` für den Link „Passwort festlegen“, `user.update` für `welcomeMailSentAt`), beide an den Mandanten des Zielbenutzers. Vorher **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) |
|
||||
| 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 | 11 | 0 | **quick-260930:** +1 gebunden (`jwt.strategy.ts`, Konto je Anfrage aus der Datenbank). Vorher **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) |
|
||||
| calendar | 0 | 12 | 0 | **war 12/0** — Aufgabe 2 (260911-cwh) hat `calendar.service.ts` vollständig auf `forTenant()` umgestellt: `getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rückschreibungen von `fetchAndCacheEvents` — je Methode ein Klient. Anders als bei den sieben Bereichen davor bleibt KEIN ungebundener Rest übrig: `CalendarSource` trägt eine Pflicht-Mandantenkennung, und kein Pfad dieses Bereichs liest über Mandanten hinweg |
|
||||
| tenant | 8 | 3 | 0 | **war 8/0** — 260911-e2s (Aufgabe 3) hat drei gebundene Benutzerzähler in `tenant.controller.ts` eingeführt (Fan-out je Mandant nach dem Muster von `UserService.findAllForPlatformAdmin`, ersetzt die drei vorherigen Relationszähler); die acht `tenant`-Zugriffe selbst BLEIBEN ungebunden — `Tenant` trägt keine Regel in irgendeiner ausgelieferten Migration (260911-e2s Aufgabe 1, Prüfung 1/2), hier ist Ungebundenheit richtig, nicht geduldet |
|
||||
| favorites | 0 | 12 | 0 | **Nachgemessen quick-260924-m4n: 12 gebundene Rohtreffer** — die Zeile nannte 8; die vier weiteren `tenantPrisma.favoriteLink.`-Rohtreffer kamen mit quick-260923-lrr (Favoriten-Symbol hochladen/ausliefern/entfernen) in `favorites.service.ts` hinzu, ohne dass die Zeile nachgezogen wurde. Vorher: **war 7/0** — 260911-gwh (Aufgabe 2) hat `favorites.service.ts` vollständig auf `forTenant()` umgestellt: `list`, `create`, `update`, `remove`, `getIconBytes` laufen je über EINEN Klienten `tenantPrisma` (7 gebundene `favoriteLink`-Rohtreffer); `create` prüft zusätzlich über einen gebundenen `widgetInstance.findUnique`, dass das Ziel-Widget dem Aufrufer gehört (T-GWH-05, Befund F aus Aufgabe 1: der Fremdschlüssel prüft am Zeilenschutz vorbei) — der achte gebundene Rohtreffer dieser Zeile |
|
||||
@@ -727,6 +727,7 @@ werden.
|
||||
|---|---|---|---|---|
|
||||
| apps/api/src/auth/auth.service.ts | passwordResetToken | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `User` (Migration 20260618112133). `requestPasswordReset`/`resetPassword` laufen vollstaendig ueber `forTenant()` (Etappe 1, WINDOWS #20, Aufgabe 1) — von der alten, nur `this.prisma.*` erkennenden Suche nie erfasst, weil bereits gebunden; die erweiterte Erkennung aus Aufgabe 2 (260909-ipc) macht diese Fundstelle erstmals sichtbar. |
|
||||
| apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gebunden | Klassenkorrektur (260911-fh9, Aufgabe 2/3): wechselt von `gemischt` auf `gebunden` — `getMe`, `changePassword`, `adminResetPassword` binden seit Aufgabe 2 je über GENAU EINEN Klienten `tenantPrisma` an den Mandanten aus dem Sitzungsnachweis (`@CurrentUser().tenantId`); für die oberste Rolle (SUPER_ADMIN) löst der Controller den Mandanten des ZIELS über den gebundenen Fan-out `UserService.findByIdForPlatformAdmin` auf. `adminResetPassword` verweigert zusätzlich einem Nicht-SUPER_ADMIN das Kennwort eines SUPER_ADMIN (T-FH9-04). Die drei Anmeldesuchen (`validateUser`, `requestPasswordReset`, `resetPassword`) laufen weiterhin über die drei SECURITY-DEFINER-Funktionen (`$queryRaw`, keine Modellzugriffe — `$` liegt nicht in `[a-zA-Z]`) und bleiben unverändert auf dem ungebundenen Klienten. Etappe-3-Vorbehalt: die Bindung hängt am Claim `tenantId` und an `User.id` (plattformweite UUID), nicht an `username`/`email` — der Anmeldeweg-Umbau für je Mandant eindeutige Anmeldenamen betrifft diese Bindung nicht, siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h4)(a). |
|
||||
| apps/api/src/auth/strategies/jwt.strategy.ts | user | muss-mandantengebunden | gebunden | **quick-260930:** neu — `JwtStrategy.validate` liest bei JEDER Anfrage das angemeldete Konto per Primaerschluessel (`findUnique` auf `id` aus `sub`) ueber `forTenant(this.prisma, payload.tenantId)` — Rolle, Aktiv-Status und Kennwort-Pflicht kommen damit aus der Datenbank statt aus dem 30-Tage-Token. Ein Rohtreffer, ein Klient. Zusaetzlich zweites Netz `user.tenantId !== payload.tenantId` -> 401. |
|
||||
| apps/api/src/bug-reports/bug-reports.service.ts | user | muss-mandantengebunden | gebunden | Fehler-melden-Knopf (quick-260914-m97): eine gebundene Leseoperation auf die Zeile des angemeldeten Benutzers (Anzeigename, E-Mail, Rolle fuer den Bericht), Mandant ausschliesslich aus dem Sitzungsnachweis. |
|
||||
| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | gebunden | Kalenderquellen eines Nutzers je Mandant gebunden (encryptedPassword traegt Zugangsdaten zu externen Exchange-/CalDAV-Servern), `tenantId`-Spalte vorhanden. Seit 260911-cwh (Aufgabe 2) laufen alle zwoelf Zugriffe (`getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rueckschreibungen von `fetchAndCacheEvents`) ueber `forTenant()`, ein Klient je Methode; `fetchAndCacheEvents`/`refreshCacheInBackground` nehmen die Mandantenkennung als Parameter, Letztere traegt die Kennung der urspruenglichen Anfrage. Die drei Besitzpruefungen (`updateSource`/`deleteSource`/`testConnection`, Vergleich gegen `userId` aus dem Sitzungsnachweis) bleiben zusaetzlich bestehen — die Regel auf `CalendarSource` kennt keine Benutzerdimension (260911-cwh, Aufgabe 1, gemessen), sie sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz zwischen Kollegen DESSELBEN Mandanten. Benutzerdimension seit 20260911120000 (260911-nke). |
|
||||
| apps/api/src/dashboard/dashboard-images.service.ts | dashboardImage | muss-mandantengebunden | gebunden | **quick-260924-m4n:** Stand zurück von `system-gebunden` auf `gebunden`. Stufe 2 der Umstellung (Migration 20260924120000_dashboard_image_drop_data) löscht die Spalte `data` und macht `storagePath` zur Pflicht; der Bootstrap-Umzug hatte auf allen Servern gearbeitet und ist samt seinem einzigen `forSystem()`-Aufruf entfernt (Eintrag aus `FORSYSTEM_ALLOWED_CALL_SITES` gestrichen). Dieselbe Migration entfernt die `system_read_policy` auf "DashboardImage" — auf der Tabelle bleibt allein `tenant_isolation_policy` (Mandant UND Benutzer). Alle vier Anfragewege laufen wie bisher ausschließlich über `forTenant(this.prisma, tenantId, userId)`; der Upload vergibt die UUID jetzt selbst und legt die Zeile gleich mit Pfad an. Vorher: **260922-hk4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `onApplicationBootstrap()` zieht die Bilder einmalig aus der Spalte `data` in den Dateibereich (`user-files/dashboard-images/<userId>/<id>.<ext>`) und muss dafür die noch nicht umgezogenen Zeilen ALLER Mandanten sehen (`const systemPrisma = forSystem(this.prisma)`, ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht über `system_read_policy … FOR SELECT` auf "DashboardImage", Migration 20260922120000). GESCHRIEBEN wird auch dort je Zeile über `forTenant(prisma, row.tenantId, row.userId)` — einmal-lesen-viele-bedienen, Muster DKV-Planer. Die Bytes selbst liegen seither auf der Platte, die Zeile hält nur noch `storagePath` (Muster `User.avatarPath`); der Dateiname ist IMMER servergeneriert (UUID der Zeile + Endung aus dem ERKANNTEN Mime-Typ), `originalName` kommt in keinem Pfad vor (T-HK4-01). Alle vier Anfragewege sind unverändert mandantengebunden: Hochgeladene Bilder des Bilderrahmen-Widgets (quick-260921-pi9), gehoeren dem hochladenden Benutzer; `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260921120000, Form aus 20260911120000). Alle vier Methoden (`list`, `upload`, `getBytes`, `remove`) holen je einen Klienten `const tenantPrisma = forTenant(this.prisma, tenantId, userId)`; Liste und Zaehler filtern zusaetzlich explizit `where: { tenantId, userId }`, `getBytes`/`remove` pruefen den Besitz anwendungsseitig (`row.userId !== userId || row.tenantId !== tenantId` -> 404, nie 403) — zweites Netz, kein Ersatz, weil der RLS-Schalter heute aus ist. `select` der Liste/Upload-Antwort ohne `data` (Bytes nur ueber `GET :id`). |
|
||||
@@ -800,6 +801,8 @@ werden.
|
||||
| apps/api/src/user/admin-seed.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Legt beim ersten Start den Standard-Mandanten selbst an und liest beim Start alle Mandanten fuer die Standardgruppen-Reparatur — `Tenant` hat keine `tenantId`-Spalte und traegt keinen Zeilenschutz (Aufgabe 1, `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`). Fuenfter und bislang einziger bereits vollstaendig richtiger Fall der Hintergrunddienst-Falle (Befund K, siehe Abschnitt unten). 3c-Befund (260914-eym): einziger Lesezugriff außerhalb der Schleife, `Tenant` ohne Regel — kein Systemkontext nötig, Datei unverändert, Stand bleibt `ungebunden`. |
|
||||
| apps/api/src/user/admin-seed.service.ts | user | beides | gemischt | Klassenkorrektur (260910-das, Aufgabe 3): wechselt von `bewusst-uebergreifend` auf `beides`, weil die bisherige Begruendung ("es gibt strukturell keinen Mandanten zum Binden") nachweislich FALSCH war (Befund J) — der Mandant wird eine Anweisung vorher angelegt und ist bekannt. Die Erstanlage-Pruefung bleibt bewusst ungebunden (kein Mandant existiert zu diesem Zeitpunkt, `username` ist plattformweit eindeutig); die Erstanlage des Administrators selbst laeuft seit Aufgabe 2 ueber `forTenant()`, gebunden an den unmittelbar zuvor angelegten Mandanten. Eine P2002-Kollision beim Anlegen wird wie "Administrator existiert bereits" behandelt statt den Start abzubrechen (Befund I). |
|
||||
| apps/api/src/user/user.controller.ts | user | muss-mandantengebunden | gebunden | Nutzerverwaltung innerhalb des Mandanten des anfragenden Admins (260910-das, Aufgabe 3): die Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege (rollenabhaengig ueber `UserService.findById`/`findByIdForPlatformAdmin`) und alle fuenf Selbstbedienungszugriffe (Bild hochladen/loeschen/ausliefern, Akzentfarbe) laufen ueber `forTenant()`; seit quick-260925-bow ebenso die drei Zugriffe der zwei Selbstbedienungswege des „Was ist neu“-Fensters (`GET me/release-notice` liest, `POST me/release-seen` liest und schreibt `lastSeenReleaseVersion`), weiterhin `forTenant()` mit `where: { id: currentUser.id }` und ohne Kennungsparameter; seit quick-260928-ujj schreibt der Selbstbedienungsweg `PATCH me/dashboard-background` das Feld `dashboardBackground` (vorher geprueft durch `parseDashboardBackground` aus `@tessera/shared`), ebenfalls `forTenant()` mit `where: { id: currentUser.id }` und ohne Kennungsparameter; die Rollenverzweigung zwischen mandantengebundener ADMIN-Sicht und der uebergreifenden `SUPER_ADMIN`-Sicht (ueber `UserService.findAllForPlatformAdmin`) bleibt bestehen. Der wirkungslose Selbstloesch-Riegel (Befund H, verglich gegen `currentUser.sub`, ein im Sitzungsnachweis nicht existierendes Feld) ist auf `currentUser.id` korrigiert. |
|
||||
| apps/api/src/user/welcome-mail.service.ts | passwordResetToken | muss-mandantengebunden | gebunden | Willkommensmail (Administrator → Benutzer): für ein LOKALES Konto legt `send` einen Token für den Link „Passwort festlegen“ an — dieselbe Tabelle, dieselbe Seite `/reset-password/<token>` und dieselbe Frist (`PASSWORD_RESET_TOKEN_TTL_MS`) wie `auth.service.ts`/`requestPasswordReset`. Gebunden über `forTenant(this.prisma, target.tenantId)` an den Mandanten des ZIELBENUTZERS, den `UserController.sendWelcomeMail` vorher rollenabhängig aufgelöst und gegen Mandanten- und Zielrollen-Riegel geprüft hat (dieselbe Regel wie `update`). Kein eigenes `tenantId` an der Tabelle, RLS über Join auf `User`. |
|
||||
| apps/api/src/user/welcome-mail.service.ts | user | muss-mandantengebunden | gebunden | Willkommensmail: nach erfolgreichem Versand setzt `send` `welcomeMailSentAt` (`user.update` mit `where: { id: target.id }`, schmaler `select`), gebunden über denselben Klienten an den Mandanten des Zielbenutzers. Gelesen wird hier nichts — der Zielbenutzer kommt fertig aufgelöst aus dem Controller. |
|
||||
| apps/api/src/user/user.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Schleifentreiber der neuen Plattform-Administratorsicht (`findAllForPlatformAdmin`/`findByIdForPlatformAdmin`, 260910-das, Aufgabe 2, Befund F/N) — `Tenant` hat keine `tenantId`-Spalte und traegt keinen Zeilenschutz (Aufgabe 1, `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`). |
|
||||
| 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). |
|
||||
|
||||
Reference in New Issue
Block a user