feat: Profilseite, Etikettendruck mit QR-Code, mehr Automatisierungen und Entra-ID-SSO
- Profilseite mit MFA-Einrichtung, Passwort/E-Mail/Name aendern, Konto deaktivieren - Bearbeiten-Buttons fuer Bienenstaende/Voelker/Koeniginnen/Aufgaben-Datensaetze - Restliche englische Enum-Werte durch deutsche Labels ersetzt (SelectValue-Fix) - Sidebar-Eintraege fuer Ernten/Schleuderungen ergaenzt - Automatisierungen von 3 auf 6 Regeln erweitert (Schwarmstimmung, kritischer Gesundheitsstatus, Lagerbestand) - Responsive Grid-Layout fuer Volk-/Koeniginnen-/Honigchargen-/Bienenstand-Detailseiten - Etikettenvorlagen mit Platzhaltern inkl. QR-Code (verweist auf Herkunftsseite) und PDF-Druck - Entra-ID-SSO (Authorization Code + PKCE, kein Auto-Provisioning, Secrets nur ueber Environment-Variablen)
This commit is contained in:
1 parent
0a52b29c57
commit
8432313fa9
83 files changed
+2508
-113
No files matched your search
@@ -96,6 +96,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
|||||||
5. **Backup-fähige Struktur**: kein eigenes Backup-Tooling im Code. Umgesetzt als: reproduzierbare Prisma-Migrationen (kein manuelles Schema-Drift), dokumentierte `pg_dump`/PITR-Strategie in diesem Dokument (Betrieb obliegt dem bestehenden Cluster/DBA), `/health`-Endpoint für Betriebsüberwachung.
|
5. **Backup-fähige Struktur**: kein eigenes Backup-Tooling im Code. Umgesetzt als: reproduzierbare Prisma-Migrationen (kein manuelles Schema-Drift), dokumentierte `pg_dump`/PITR-Strategie in diesem Dokument (Betrieb obliegt dem bestehenden Cluster/DBA), `/health`-Endpoint für Betriebsüberwachung.
|
||||||
6. **Deviations vom Leitentscheidungs-Rahmen**: bisher keine. Wird ergänzt, falls während der Umsetzung ein konkreter Grund für eine Abweichung von NestJS/Prisma/Next.js/Tailwind/shadcn/TanStack Query auftritt.
|
6. **Deviations vom Leitentscheidungs-Rahmen**: bisher keine. Wird ergänzt, falls während der Umsetzung ein konkreter Grund für eine Abweichung von NestJS/Prisma/Next.js/Tailwind/shadcn/TanStack Query auftritt.
|
||||||
7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder.
|
7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder.
|
||||||
|
8. **Entra ID SSO ohne Auto-Provisioning**: `POST/GET /api/v1/auth/sso/*` implementiert Authorization Code + PKCE gegen Entra ID über `openid-client`. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktiven `User`-Datensatz gematcht – es wird kein neuer Benutzer automatisch angelegt (Just-in-Time-Provisioning), passend zum bestehenden Modell, dass Admins Benutzer manuell anlegen. Eine erfolgreiche SSO-Anmeldung überspringt die App-eigene TOTP-MFA-Abfrage, da der Identity Provider bereits eine eigene (typischerweise stärkere) Authentifizierung durchgesetzt hat. Der PKCE-`code_verifier` wird kurzlebig (5 min) in einem httpOnly-`SameSite=Lax`-Cookie zwischengespeichert statt serverseitig, damit der Flow ohne Sticky-Sessions über mehrere Backend-Replicas hinweg funktioniert; der Rückweg vom Callback zum Frontend nutzt ein kurzlebiges (60 s), zweckgebundenes JWT statt des echten Access-Tokens in der Redirect-URL. `OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`/`OIDC_REDIRECT_URI` kommen ausschließlich aus Environment-Variablen; fehlen sie, meldet `GET /api/v1/auth/sso/status` `enabled: false` und die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern.
|
||||||
|
|
||||||
## 7. Kubernetes
|
## 7. Kubernetes
|
||||||
|
|
||||||
|
|||||||
@@ -19,3 +19,12 @@ COOKIE_SECURE=false
|
|||||||
# Bei horizontaler Skalierung (>1 Replica) muss dies auf ein geteiltes
|
# Bei horizontaler Skalierung (>1 Replica) muss dies auf ein geteiltes
|
||||||
# Volume (z.B. ReadWriteMany-PVC) zeigen, siehe ARCHITECTURE.md Annahme 7.
|
# Volume (z.B. ReadWriteMany-PVC) zeigen, siehe ARCHITECTURE.md Annahme 7.
|
||||||
DOCUMENTS_STORAGE_PATH="./uploads"
|
DOCUMENTS_STORAGE_PATH="./uploads"
|
||||||
|
|
||||||
|
# Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert
|
||||||
|
# (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start).
|
||||||
|
# OIDC_ISSUER_URL ist die v2.0-Metadaten-URL des Tenants, OIDC_REDIRECT_URI
|
||||||
|
# muss exakt der in der App-Registrierung hinterlegten Redirect-URI entsprechen.
|
||||||
|
OIDC_ISSUER_URL="https://login.microsoftonline.com/<tenant-id>/v2.0"
|
||||||
|
OIDC_CLIENT_ID="change-me-entra-client-id"
|
||||||
|
OIDC_CLIENT_SECRET="change-me-entra-client-secret"
|
||||||
|
OIDC_REDIRECT_URI="http://localhost:3001/api/v1/auth/sso/callback"
|
||||||
Generated
+32
@@ -24,6 +24,7 @@
|
|||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"helmet": "^8.3.0",
|
"helmet": "^8.3.0",
|
||||||
|
"openid-client": "^6.8.4",
|
||||||
"otplib": "^13.5.0",
|
"otplib": "^13.5.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
@@ -8681,6 +8682,15 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "6.2.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz",
|
||||||
|
"integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-md5": {
|
"node_modules/js-md5": {
|
||||||
"version": "0.8.3",
|
"version": "0.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
|
||||||
@@ -9648,6 +9658,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/oauth4webapi": {
|
||||||
|
"version": "3.8.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.7.tgz",
|
||||||
|
"integrity": "sha512-4RxcKxXjuItDFZ20RRPf4YTw3kpeXJyCgJFxVzJ068A7PNJ18st2Dg90tlC1LkSDS0GecroagCLHYEIVUhCAkw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -9712,6 +9731,19 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/openid-client": {
|
||||||
|
"version": "6.8.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.4.tgz",
|
||||||
|
"integrity": "sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jose": "^6.2.2",
|
||||||
|
"oauth4webapi": "^3.8.5"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"helmet": "^8.3.0",
|
"helmet": "^8.3.0",
|
||||||
|
"openid-client": "^6.8.4",
|
||||||
"otplib": "^13.5.0",
|
"otplib": "^13.5.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
@@ -90,7 +91,7 @@
|
|||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"node_modules/(?!(otplib|@otplib|@scure|@noble)/)"
|
"node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose)/)"
|
||||||
],
|
],
|
||||||
"collectCoverageFrom": [
|
"collectCoverageFrom": [
|
||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
|
|||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "AutomationTriggerType" ADD VALUE 'SWARM_MOOD_HIGH';
|
||||||
|
ALTER TYPE "AutomationTriggerType" ADD VALUE 'COLONY_HEALTH_CRITICAL';
|
||||||
|
ALTER TYPE "AutomationTriggerType" ADD VALUE 'STOCK_ITEM_LOW';
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "LabelTemplate" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"widthMm" DOUBLE PRECISION NOT NULL DEFAULT 90,
|
||||||
|
"heightMm" DOUBLE PRECISION NOT NULL DEFAULT 35,
|
||||||
|
"isDefault" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "LabelTemplate_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "LabelTemplate_active_idx" ON "LabelTemplate"("active");
|
||||||
@@ -853,6 +853,22 @@ model Product {
|
|||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Etikettenvorlage mit {{Platzhalter}}-Text, wird beim Drucken mit den
|
||||||
|
// Daten einer Abfuellung befuellt (siehe LabelTemplatesService.renderLabelsPdf).
|
||||||
|
model LabelTemplate {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
content String
|
||||||
|
widthMm Float @default(90)
|
||||||
|
heightMm Float @default(35)
|
||||||
|
isDefault Boolean @default(false)
|
||||||
|
active Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([active])
|
||||||
|
}
|
||||||
|
|
||||||
model BottlingBatch {
|
model BottlingBatch {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
honeyBatchId String
|
honeyBatchId String
|
||||||
@@ -981,6 +997,9 @@ enum AutomationTriggerType {
|
|||||||
QUEENLESS_NO_EGGS
|
QUEENLESS_NO_EGGS
|
||||||
VARROA_THRESHOLD
|
VARROA_THRESHOLD
|
||||||
FEED_STATUS_CRITICAL
|
FEED_STATUS_CRITICAL
|
||||||
|
SWARM_MOOD_HIGH
|
||||||
|
COLONY_HEALTH_CRITICAL
|
||||||
|
STOCK_ITEM_LOW
|
||||||
}
|
}
|
||||||
|
|
||||||
model AutomationRule {
|
model AutomationRule {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { StatisticsModule } from './statistics/statistics.module';
|
|||||||
import { WebhooksModule } from './webhooks/webhooks.module';
|
import { WebhooksModule } from './webhooks/webhooks.module';
|
||||||
import { DocumentsModule } from './documents/documents.module';
|
import { DocumentsModule } from './documents/documents.module';
|
||||||
import { AutomationModule } from './automation/automation.module';
|
import { AutomationModule } from './automation/automation.module';
|
||||||
|
import { LabelTemplatesModule } from './label-templates/label-templates.module';
|
||||||
import { ImportModule } from './import/import.module';
|
import { ImportModule } from './import/import.module';
|
||||||
import { PublicModule } from './public/public.module';
|
import { PublicModule } from './public/public.module';
|
||||||
import { TaskTemplatesModule } from './task-templates/task-templates.module';
|
import { TaskTemplatesModule } from './task-templates/task-templates.module';
|
||||||
@@ -79,6 +80,7 @@ import { PermissionsModule } from './auth/permissions/permissions.module';
|
|||||||
WebhooksModule,
|
WebhooksModule,
|
||||||
DocumentsModule,
|
DocumentsModule,
|
||||||
AutomationModule,
|
AutomationModule,
|
||||||
|
LabelTemplatesModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
PublicModule,
|
PublicModule,
|
||||||
TaskTemplatesModule,
|
TaskTemplatesModule,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Req,
|
Req,
|
||||||
Res,
|
Res,
|
||||||
UnauthorizedException,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
import { Throttle } from '@nestjs/throttler';
|
import { Throttle } from '@nestjs/throttler';
|
||||||
@@ -19,49 +18,23 @@ import { LoginDto } from './dto/login.dto';
|
|||||||
import { AccessTokenDto } from './dto/access-token.dto';
|
import { AccessTokenDto } from './dto/access-token.dto';
|
||||||
import { MfaVerifyDto } from './dto/mfa-verify.dto';
|
import { MfaVerifyDto } from './dto/mfa-verify.dto';
|
||||||
import { AuthService, CurrentUserProfile } from './auth.service';
|
import { AuthService, CurrentUserProfile } from './auth.service';
|
||||||
import { parseDurationToMs } from './util/duration';
|
|
||||||
import { SENSITIVE_ACTION_THROTTLE } from './util/throttle';
|
import { SENSITIVE_ACTION_THROTTLE } from './util/throttle';
|
||||||
|
import {
|
||||||
|
clearRefreshTokenCookie,
|
||||||
|
extractRefreshToken,
|
||||||
|
setRefreshTokenCookie,
|
||||||
|
} from './util/refresh-token-cookie';
|
||||||
|
|
||||||
interface MfaRequiredResponse {
|
interface MfaRequiredResponse {
|
||||||
mfaRequired: true;
|
mfaRequired: true;
|
||||||
mfaToken: string;
|
mfaToken: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const REFRESH_TOKEN_COOKIE = 'refreshToken';
|
|
||||||
const REFRESH_TOKEN_COOKIE_PATH = '/api/v1/auth';
|
|
||||||
|
|
||||||
@ApiTags('auth')
|
@ApiTags('auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly authService: AuthService) {}
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
private setRefreshTokenCookie(res: Response, refreshToken: string): void {
|
|
||||||
res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, {
|
|
||||||
httpOnly: true,
|
|
||||||
// Explicit opt-out for local HTTP dev only; every real deployment must
|
|
||||||
// set COOKIE_SECURE=true so the refresh-token cookie is never sent
|
|
||||||
// over plaintext HTTP.
|
|
||||||
secure: process.env.COOKIE_SECURE !== 'false',
|
|
||||||
sameSite: 'lax',
|
|
||||||
path: REFRESH_TOKEN_COOKIE_PATH,
|
|
||||||
maxAge: parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private clearRefreshTokenCookie(res: Response): void {
|
|
||||||
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: REFRESH_TOKEN_COOKIE_PATH });
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractRefreshToken(req: Request): string {
|
|
||||||
const refreshToken = (req.cookies as Record<string, string> | undefined)?.[
|
|
||||||
REFRESH_TOKEN_COOKIE
|
|
||||||
];
|
|
||||||
if (!refreshToken) {
|
|
||||||
throw new UnauthorizedException('Missing refresh token');
|
|
||||||
}
|
|
||||||
return refreshToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Throttle(SENSITIVE_ACTION_THROTTLE)
|
@Throttle(SENSITIVE_ACTION_THROTTLE)
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@@ -74,7 +47,7 @@ export class AuthController {
|
|||||||
if (result.mfaRequired) {
|
if (result.mfaRequired) {
|
||||||
return { mfaRequired: true, mfaToken: result.mfaToken };
|
return { mfaRequired: true, mfaToken: result.mfaToken };
|
||||||
}
|
}
|
||||||
this.setRefreshTokenCookie(res, result.refreshToken);
|
setRefreshTokenCookie(res, result.refreshToken);
|
||||||
return { accessToken: result.accessToken };
|
return { accessToken: result.accessToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +61,7 @@ export class AuthController {
|
|||||||
): Promise<AccessTokenDto> {
|
): Promise<AccessTokenDto> {
|
||||||
const { accessToken, refreshToken } =
|
const { accessToken, refreshToken } =
|
||||||
await this.authService.verifyMfaAndLogin(dto.mfaToken, dto.code);
|
await this.authService.verifyMfaAndLogin(dto.mfaToken, dto.code);
|
||||||
this.setRefreshTokenCookie(res, refreshToken);
|
setRefreshTokenCookie(res, refreshToken);
|
||||||
return { accessToken };
|
return { accessToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,9 +74,9 @@ export class AuthController {
|
|||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
): Promise<AccessTokenDto> {
|
): Promise<AccessTokenDto> {
|
||||||
const { accessToken, refreshToken } = await this.authService.refresh(
|
const { accessToken, refreshToken } = await this.authService.refresh(
|
||||||
this.extractRefreshToken(req),
|
extractRefreshToken(req),
|
||||||
);
|
);
|
||||||
this.setRefreshTokenCookie(res, refreshToken);
|
setRefreshTokenCookie(res, refreshToken);
|
||||||
return { accessToken };
|
return { accessToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,8 +87,8 @@ export class AuthController {
|
|||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.authService.logout(this.extractRefreshToken(req));
|
await this.authService.logout(extractRefreshToken(req));
|
||||||
this.clearRefreshTokenCookie(res);
|
clearRefreshTokenCookie(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
|
|||||||
@@ -6,10 +6,18 @@ import { AuthController } from './auth.controller';
|
|||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
import { MfaModule } from './mfa/mfa.module';
|
import { MfaModule } from './mfa/mfa.module';
|
||||||
|
import { SsoModule } from './sso/sso.module';
|
||||||
|
import { SsoController } from './sso/sso.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PassportModule, JwtModule.register({}), UsersModule, MfaModule],
|
imports: [
|
||||||
controllers: [AuthController],
|
PassportModule,
|
||||||
|
JwtModule.register({}),
|
||||||
|
UsersModule,
|
||||||
|
MfaModule,
|
||||||
|
SsoModule,
|
||||||
|
],
|
||||||
|
controllers: [AuthController, SsoController],
|
||||||
providers: [AuthService, JwtStrategy],
|
providers: [AuthService, JwtStrategy],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
@@ -229,6 +229,33 @@ describe('AuthService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('issueTokensForSsoUser', () => {
|
||||||
|
it('issues tokens and records LOGIN for an active user', async () => {
|
||||||
|
usersService.findById.mockResolvedValue(activeUser);
|
||||||
|
prisma.refreshToken.create.mockResolvedValue({});
|
||||||
|
|
||||||
|
const result = await authService.issueTokensForSsoUser(activeUser.id);
|
||||||
|
|
||||||
|
expect(result.accessToken).toBe('signed.jwt.token');
|
||||||
|
expect(prisma.refreshToken.create).toHaveBeenCalled();
|
||||||
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: 'LOGIN', userId: activeUser.id }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when the user is deactivated', async () => {
|
||||||
|
usersService.findById.mockResolvedValue({
|
||||||
|
...activeUser,
|
||||||
|
isActive: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
authService.issueTokensForSsoUser(activeUser.id),
|
||||||
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
expect(prisma.refreshToken.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('refresh', () => {
|
describe('refresh', () => {
|
||||||
it('throws when the refresh token is unknown', async () => {
|
it('throws when the refresh token is unknown', async () => {
|
||||||
prisma.refreshToken.findUnique.mockResolvedValue(null);
|
prisma.refreshToken.findUnique.mockResolvedValue(null);
|
||||||
|
|||||||
@@ -144,6 +144,26 @@ export class AuthService {
|
|||||||
return { mfaRequired: false, accessToken, refreshToken };
|
return { mfaRequired: false, accessToken, refreshToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Issues our own access/refresh token pair for a user already authenticated via SSO. */
|
||||||
|
async issueTokensForSsoUser(userId: string): Promise<AuthTokens> {
|
||||||
|
const user = await this.usersService.findById(userId);
|
||||||
|
if (!user.isActive) {
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
const accessToken = this.signAccessToken({
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
});
|
||||||
|
const refreshToken = await this.issueRefreshToken(user.id);
|
||||||
|
await this.auditLog.record({
|
||||||
|
entityType: 'AuthSession',
|
||||||
|
entityId: user.id,
|
||||||
|
action: 'LOGIN',
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
return { accessToken, refreshToken };
|
||||||
|
}
|
||||||
|
|
||||||
async verifyMfaAndLogin(mfaToken: string, code: string): Promise<AuthTokens> {
|
async verifyMfaAndLogin(mfaToken: string, code: string): Promise<AuthTokens> {
|
||||||
const { userId } = this.mfaService.verifyChallengeToken(mfaToken);
|
const { userId } = this.mfaService.verifyChallengeToken(mfaToken);
|
||||||
const user = await this.usersService.findById(userId);
|
const user = await this.usersService.findById(userId);
|
||||||
|
|||||||
@@ -194,6 +194,18 @@ export const PERMISSION_CATALOG = [
|
|||||||
],
|
],
|
||||||
1600,
|
1600,
|
||||||
),
|
),
|
||||||
|
...group(
|
||||||
|
'Etiketten',
|
||||||
|
'label-templates',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Anlegen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
['deactivate', 'Deaktivieren'],
|
||||||
|
['print', 'Drucken'],
|
||||||
|
],
|
||||||
|
1650,
|
||||||
|
),
|
||||||
...group(
|
...group(
|
||||||
'Inventar',
|
'Inventar',
|
||||||
'equipment',
|
'equipment',
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { Public } from '../decorators/public.decorator';
|
||||||
|
import { AuthService } from '../auth.service';
|
||||||
|
import { AccessTokenDto } from '../dto/access-token.dto';
|
||||||
|
import { SENSITIVE_ACTION_THROTTLE } from '../util/throttle';
|
||||||
|
import { setRefreshTokenCookie } from '../util/refresh-token-cookie';
|
||||||
|
import { SsoService } from './sso.service';
|
||||||
|
|
||||||
|
const PKCE_COOKIE = 'oidcPkceVerifier';
|
||||||
|
const PKCE_COOKIE_PATH = '/api/v1/auth/sso';
|
||||||
|
const PKCE_COOKIE_MAX_AGE_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// Scoped to /complete only - the completion endpoint is the only place that
|
||||||
|
// ever needs to read it, and it's never sent on the wider /auth/sso/* path.
|
||||||
|
const SSO_CODE_COOKIE = 'oidcSsoCode';
|
||||||
|
const SSO_CODE_COOKIE_PATH = '/api/v1/auth/sso/complete';
|
||||||
|
const SSO_CODE_COOKIE_MAX_AGE_MS = 60 * 1000;
|
||||||
|
|
||||||
|
interface PkceCookiePayload {
|
||||||
|
codeVerifier: string;
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('auth')
|
||||||
|
@Controller('auth/sso')
|
||||||
|
export class SsoController {
|
||||||
|
constructor(
|
||||||
|
private readonly ssoService: SsoService,
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private frontendLoginUrl(): string {
|
||||||
|
return `${process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000'}/login`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cookieOptions(path: string, maxAge: number) {
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.COOKIE_SECURE !== 'false',
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
path,
|
||||||
|
maxAge,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('status')
|
||||||
|
status(): { enabled: boolean } {
|
||||||
|
return { enabled: this.ssoService.isConfigured() };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('login')
|
||||||
|
async login(@Res() res: Response): Promise<void> {
|
||||||
|
const { redirectUrl, codeVerifier, state } =
|
||||||
|
await this.ssoService.buildAuthorizationRequest();
|
||||||
|
const payload: PkceCookiePayload = { codeVerifier, state };
|
||||||
|
res.cookie(
|
||||||
|
PKCE_COOKIE,
|
||||||
|
JSON.stringify(payload),
|
||||||
|
this.cookieOptions(PKCE_COOKIE_PATH, PKCE_COOKIE_MAX_AGE_MS),
|
||||||
|
);
|
||||||
|
res.redirect(redirectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('callback')
|
||||||
|
async callback(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||||
|
const rawCookie = (req.cookies as Record<string, string> | undefined)?.[
|
||||||
|
PKCE_COOKIE
|
||||||
|
];
|
||||||
|
res.clearCookie(PKCE_COOKIE, { path: PKCE_COOKIE_PATH });
|
||||||
|
|
||||||
|
const pkce = this.parsePkceCookie(rawCookie);
|
||||||
|
if (!pkce) {
|
||||||
|
res.redirect(`${this.frontendLoginUrl()}?ssoError=session_expired`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const callbackUrl = new URL(
|
||||||
|
req.originalUrl,
|
||||||
|
`${req.protocol}://${req.get('host')}`,
|
||||||
|
);
|
||||||
|
const result = await this.ssoService.handleCallback(
|
||||||
|
callbackUrl,
|
||||||
|
pkce.codeVerifier,
|
||||||
|
pkce.state,
|
||||||
|
);
|
||||||
|
if (result.status === 'error') {
|
||||||
|
res.redirect(`${this.frontendLoginUrl()}?ssoError=${result.reason}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one-time completion code never touches the URL (history, server
|
||||||
|
// logs, Referer headers) - it travels only in an httpOnly cookie scoped
|
||||||
|
// to the /complete endpoint, which the frontend calls right after.
|
||||||
|
res.cookie(
|
||||||
|
SSO_CODE_COOKIE,
|
||||||
|
result.ssoCode,
|
||||||
|
this.cookieOptions(SSO_CODE_COOKIE_PATH, SSO_CODE_COOKIE_MAX_AGE_MS),
|
||||||
|
);
|
||||||
|
res.redirect(`${this.frontendLoginUrl()}?ssoPending=1`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Throttle(SENSITIVE_ACTION_THROTTLE)
|
||||||
|
@Post('complete')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async complete(
|
||||||
|
@Req() req: Request,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
): Promise<AccessTokenDto> {
|
||||||
|
const ssoCode = (req.cookies as Record<string, string> | undefined)?.[
|
||||||
|
SSO_CODE_COOKIE
|
||||||
|
];
|
||||||
|
res.clearCookie(SSO_CODE_COOKIE, { path: SSO_CODE_COOKIE_PATH });
|
||||||
|
if (!ssoCode) {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
'SSO-Anmeldung abgelaufen, bitte erneut versuchen',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { userId } = this.ssoService.verifySsoCode(ssoCode);
|
||||||
|
const { accessToken, refreshToken } =
|
||||||
|
await this.authService.issueTokensForSsoUser(userId);
|
||||||
|
setRefreshTokenCookie(res, refreshToken);
|
||||||
|
return { accessToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePkceCookie(raw: string | undefined): PkceCookiePayload | null {
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Partial<PkceCookiePayload>;
|
||||||
|
if (
|
||||||
|
typeof parsed.codeVerifier !== 'string' ||
|
||||||
|
typeof parsed.state !== 'string'
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { codeVerifier: parsed.codeVerifier, state: parsed.state };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { UsersModule } from '../../users/users.module';
|
||||||
|
import { SsoService } from './sso.service';
|
||||||
|
|
||||||
|
// SsoController lives in AuthModule (not here) because it also depends on
|
||||||
|
// AuthService, which only AuthModule provides - keeping the controller here
|
||||||
|
// would require a circular import between AuthModule and SsoModule.
|
||||||
|
@Module({
|
||||||
|
imports: [JwtModule.register({}), UsersModule],
|
||||||
|
providers: [SsoService],
|
||||||
|
exports: [SsoService],
|
||||||
|
})
|
||||||
|
export class SsoModule {}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import {
|
||||||
|
ServiceUnavailableException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import * as client from 'openid-client';
|
||||||
|
import { SsoService } from './sso.service';
|
||||||
|
import { UsersService } from '../../users/users.service';
|
||||||
|
|
||||||
|
jest.mock('openid-client', () => ({
|
||||||
|
discovery: jest.fn(),
|
||||||
|
randomPKCECodeVerifier: jest.fn(),
|
||||||
|
calculatePKCECodeChallenge: jest.fn(),
|
||||||
|
randomState: jest.fn(),
|
||||||
|
buildAuthorizationUrl: jest.fn(),
|
||||||
|
authorizationCodeGrant: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedClient = client as jest.Mocked<typeof client>;
|
||||||
|
|
||||||
|
describe('SsoService', () => {
|
||||||
|
let ssoService: SsoService;
|
||||||
|
let usersService: { findByEmail: jest.Mock };
|
||||||
|
let jwtService: { sign: jest.Mock; verify: jest.Mock };
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
process.env.OIDC_ISSUER_URL =
|
||||||
|
'https://login.microsoftonline.com/tenant/v2.0';
|
||||||
|
process.env.OIDC_CLIENT_ID = 'client-id';
|
||||||
|
process.env.OIDC_CLIENT_SECRET = 'client-secret';
|
||||||
|
process.env.OIDC_REDIRECT_URI =
|
||||||
|
'https://api.example.com/api/v1/auth/sso/callback';
|
||||||
|
process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret';
|
||||||
|
usersService = { findByEmail: jest.fn() };
|
||||||
|
jwtService = { sign: jest.fn(), verify: jest.fn() };
|
||||||
|
ssoService = new SsoService(
|
||||||
|
usersService as unknown as UsersService,
|
||||||
|
jwtService as unknown as JwtService,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isConfigured', () => {
|
||||||
|
it('returns true when all four OIDC variables are set', () => {
|
||||||
|
expect(ssoService.isConfigured()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when a variable is missing', () => {
|
||||||
|
delete process.env.OIDC_CLIENT_SECRET;
|
||||||
|
expect(ssoService.isConfigured()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildAuthorizationRequest', () => {
|
||||||
|
it('throws ServiceUnavailableException when SSO is not configured', async () => {
|
||||||
|
delete process.env.OIDC_ISSUER_URL;
|
||||||
|
await expect(ssoService.buildAuthorizationRequest()).rejects.toThrow(
|
||||||
|
ServiceUnavailableException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discovers the issuer once and returns the authorization URL with a PKCE verifier', async () => {
|
||||||
|
const config = {} as client.Configuration;
|
||||||
|
mockedClient.discovery.mockResolvedValue(config);
|
||||||
|
mockedClient.randomPKCECodeVerifier.mockReturnValue('verifier');
|
||||||
|
mockedClient.calculatePKCECodeChallenge.mockResolvedValue('challenge');
|
||||||
|
mockedClient.randomState.mockReturnValue('random-state');
|
||||||
|
mockedClient.buildAuthorizationUrl.mockReturnValue(
|
||||||
|
new URL(
|
||||||
|
'https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize?x=1',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await ssoService.buildAuthorizationRequest();
|
||||||
|
await ssoService.buildAuthorizationRequest();
|
||||||
|
|
||||||
|
expect(result.codeVerifier).toBe('verifier');
|
||||||
|
expect(result.state).toBe('random-state');
|
||||||
|
expect(result.redirectUrl).toContain('authorize');
|
||||||
|
expect(mockedClient.discovery).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockedClient.buildAuthorizationUrl).toHaveBeenCalledWith(
|
||||||
|
config,
|
||||||
|
expect.objectContaining({
|
||||||
|
redirect_uri: process.env.OIDC_REDIRECT_URI,
|
||||||
|
code_challenge: 'challenge',
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
state: 'random-state',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleCallback', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedClient.discovery.mockResolvedValue({} as client.Configuration);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns exchange_failed when the token exchange throws', async () => {
|
||||||
|
mockedClient.authorizationCodeGrant.mockRejectedValue(
|
||||||
|
new Error('bad code'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await ssoService.handleCallback(
|
||||||
|
new URL('https://api.example.com/callback?code=x'),
|
||||||
|
'verifier',
|
||||||
|
'random-state',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ status: 'error', reason: 'exchange_failed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no_email when the ID token has neither email nor preferred_username', async () => {
|
||||||
|
mockedClient.authorizationCodeGrant.mockResolvedValue({
|
||||||
|
claims: () => ({ sub: 'abc' }),
|
||||||
|
} as unknown as Awaited<
|
||||||
|
ReturnType<typeof client.authorizationCodeGrant>
|
||||||
|
>);
|
||||||
|
|
||||||
|
const result = await ssoService.handleCallback(
|
||||||
|
new URL('https://api.example.com/callback?code=x'),
|
||||||
|
'verifier',
|
||||||
|
'random-state',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ status: 'error', reason: 'no_email' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no_account when no active local user matches the email claim', async () => {
|
||||||
|
mockedClient.authorizationCodeGrant.mockResolvedValue({
|
||||||
|
claims: () => ({ email: '[email protected]' }),
|
||||||
|
} as unknown as Awaited<
|
||||||
|
ReturnType<typeof client.authorizationCodeGrant>
|
||||||
|
>);
|
||||||
|
usersService.findByEmail.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await ssoService.handleCallback(
|
||||||
|
new URL('https://api.example.com/callback?code=x'),
|
||||||
|
'verifier',
|
||||||
|
'random-state',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ status: 'error', reason: 'no_account' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signs a short-lived sso-complete token for an active matching user', async () => {
|
||||||
|
mockedClient.authorizationCodeGrant.mockResolvedValue({
|
||||||
|
claims: () => ({ preferred_username: '[email protected]' }),
|
||||||
|
} as unknown as Awaited<
|
||||||
|
ReturnType<typeof client.authorizationCodeGrant>
|
||||||
|
>);
|
||||||
|
usersService.findByEmail.mockResolvedValue({
|
||||||
|
id: 'user-1',
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
jwtService.sign.mockReturnValue('signed-sso-code');
|
||||||
|
|
||||||
|
const result = await ssoService.handleCallback(
|
||||||
|
new URL('https://api.example.com/callback?code=x'),
|
||||||
|
'verifier',
|
||||||
|
'random-state',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ status: 'success', ssoCode: 'signed-sso-code' });
|
||||||
|
expect(mockedClient.authorizationCodeGrant).toHaveBeenCalledWith(
|
||||||
|
{},
|
||||||
|
expect.any(URL),
|
||||||
|
{ pkceCodeVerifier: 'verifier', expectedState: 'random-state' },
|
||||||
|
);
|
||||||
|
expect(jwtService.sign).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ sub: 'user-1', purpose: 'sso-complete' }),
|
||||||
|
expect.objectContaining({ expiresIn: '60s' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verifySsoCode', () => {
|
||||||
|
it('returns the userId for a valid sso-complete token', () => {
|
||||||
|
jwtService.verify.mockReturnValue({
|
||||||
|
sub: 'user-1',
|
||||||
|
purpose: 'sso-complete',
|
||||||
|
});
|
||||||
|
expect(ssoService.verifySsoCode('token')).toEqual({ userId: 'user-1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws UnauthorizedException when verification fails', () => {
|
||||||
|
jwtService.verify.mockImplementation(() => {
|
||||||
|
throw new Error('expired');
|
||||||
|
});
|
||||||
|
expect(() => ssoService.verifySsoCode('token')).toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws UnauthorizedException when the token purpose does not match', () => {
|
||||||
|
jwtService.verify.mockReturnValue({
|
||||||
|
sub: 'user-1',
|
||||||
|
purpose: 'mfa-challenge',
|
||||||
|
});
|
||||||
|
expect(() => ssoService.verifySsoCode('token')).toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import * as client from 'openid-client';
|
||||||
|
import { UsersService } from '../../users/users.service';
|
||||||
|
|
||||||
|
const SSO_COMPLETE_TOKEN_TTL = '60s';
|
||||||
|
const SSO_COMPLETE_TOKEN_PURPOSE = 'sso-complete';
|
||||||
|
|
||||||
|
interface SsoCompleteTokenPayload {
|
||||||
|
sub: string;
|
||||||
|
purpose: typeof SSO_COMPLETE_TOKEN_PURPOSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SsoCallbackResult =
|
||||||
|
| { status: 'success'; ssoCode: string }
|
||||||
|
| { status: 'error'; reason: 'exchange_failed' | 'no_email' | 'no_account' };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SsoService {
|
||||||
|
private readonly logger = new Logger(SsoService.name);
|
||||||
|
private config: client.Configuration | undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly usersService: UsersService,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
isConfigured(): boolean {
|
||||||
|
return Boolean(
|
||||||
|
process.env.OIDC_ISSUER_URL &&
|
||||||
|
process.env.OIDC_CLIENT_ID &&
|
||||||
|
process.env.OIDC_CLIENT_SECRET &&
|
||||||
|
process.env.OIDC_REDIRECT_URI,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getConfig(): Promise<client.Configuration> {
|
||||||
|
if (!this.isConfigured()) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
'Microsoft-Anmeldung ist nicht konfiguriert',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!this.config) {
|
||||||
|
this.config = await client.discovery(
|
||||||
|
new URL(process.env.OIDC_ISSUER_URL as string),
|
||||||
|
process.env.OIDC_CLIENT_ID as string,
|
||||||
|
process.env.OIDC_CLIENT_SECRET,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.config;
|
||||||
|
}
|
||||||
|
|
||||||
|
async buildAuthorizationRequest(): Promise<{
|
||||||
|
redirectUrl: string;
|
||||||
|
codeVerifier: string;
|
||||||
|
state: string;
|
||||||
|
}> {
|
||||||
|
const config = await this.getConfig();
|
||||||
|
const codeVerifier = client.randomPKCECodeVerifier();
|
||||||
|
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
|
||||||
|
const state = client.randomState();
|
||||||
|
const redirectUrl = client.buildAuthorizationUrl(config, {
|
||||||
|
redirect_uri: process.env.OIDC_REDIRECT_URI as string,
|
||||||
|
scope: 'openid email profile',
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
return { redirectUrl: redirectUrl.href, codeVerifier, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleCallback(
|
||||||
|
callbackUrl: URL,
|
||||||
|
codeVerifier: string,
|
||||||
|
state: string,
|
||||||
|
): Promise<SsoCallbackResult> {
|
||||||
|
const config = await this.getConfig();
|
||||||
|
let tokens: Awaited<ReturnType<typeof client.authorizationCodeGrant>>;
|
||||||
|
try {
|
||||||
|
tokens = await client.authorizationCodeGrant(config, callbackUrl, {
|
||||||
|
pkceCodeVerifier: codeVerifier,
|
||||||
|
expectedState: state,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`SSO callback token exchange failed: ${(error as Error).message}`,
|
||||||
|
);
|
||||||
|
return { status: 'error', reason: 'exchange_failed' };
|
||||||
|
}
|
||||||
|
const claims = tokens.claims();
|
||||||
|
const email =
|
||||||
|
(claims?.email as string | undefined) ??
|
||||||
|
(claims?.preferred_username as string | undefined);
|
||||||
|
if (!email) {
|
||||||
|
this.logger.warn(
|
||||||
|
'SSO callback succeeded but ID token had no email claim',
|
||||||
|
);
|
||||||
|
return { status: 'error', reason: 'no_email' };
|
||||||
|
}
|
||||||
|
const user = await this.usersService.findByEmail(email);
|
||||||
|
if (!user || !user.isActive) {
|
||||||
|
this.logger.warn(
|
||||||
|
`SSO login denied: no active local account for ${email}`,
|
||||||
|
);
|
||||||
|
return { status: 'error', reason: 'no_account' };
|
||||||
|
}
|
||||||
|
const ssoCode = this.jwtService.sign(
|
||||||
|
{ sub: user.id, purpose: SSO_COMPLETE_TOKEN_PURPOSE },
|
||||||
|
{
|
||||||
|
secret: process.env.JWT_ACCESS_SECRET,
|
||||||
|
expiresIn: SSO_COMPLETE_TOKEN_TTL,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { status: 'success', ssoCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
verifySsoCode(ssoCode: string): { userId: string } {
|
||||||
|
let payload: SsoCompleteTokenPayload;
|
||||||
|
try {
|
||||||
|
payload = this.jwtService.verify<SsoCompleteTokenPayload>(ssoCode, {
|
||||||
|
secret: process.env.JWT_ACCESS_SECRET,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
'SSO-Anmeldung abgelaufen, bitte erneut versuchen',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (payload.purpose !== SSO_COMPLETE_TOKEN_PURPOSE) {
|
||||||
|
throw new UnauthorizedException('Ungültiger Token');
|
||||||
|
}
|
||||||
|
return { userId: payload.sub };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { UnauthorizedException } from '@nestjs/common';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { parseDurationToMs } from './duration';
|
||||||
|
|
||||||
|
export const REFRESH_TOKEN_COOKIE = 'refreshToken';
|
||||||
|
export const REFRESH_TOKEN_COOKIE_PATH = '/api/v1/auth';
|
||||||
|
|
||||||
|
export function setRefreshTokenCookie(
|
||||||
|
res: Response,
|
||||||
|
refreshToken: string,
|
||||||
|
): void {
|
||||||
|
res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
// Explicit opt-out for local HTTP dev only; every real deployment must
|
||||||
|
// set COOKIE_SECURE=true so the refresh-token cookie is never sent
|
||||||
|
// over plaintext HTTP.
|
||||||
|
secure: process.env.COOKIE_SECURE !== 'false',
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: REFRESH_TOKEN_COOKIE_PATH,
|
||||||
|
maxAge: parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearRefreshTokenCookie(res: Response): void {
|
||||||
|
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: REFRESH_TOKEN_COOKIE_PATH });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractRefreshToken(req: Request): string {
|
||||||
|
const refreshToken = (req.cookies as Record<string, string> | undefined)?.[
|
||||||
|
REFRESH_TOKEN_COOKIE
|
||||||
|
];
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new UnauthorizedException('Missing refresh token');
|
||||||
|
}
|
||||||
|
return refreshToken;
|
||||||
|
}
|
||||||
@@ -70,7 +70,7 @@ describe('AutomationService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('findAll seeds the three default rules before returning them', async () => {
|
it('findAll seeds all default rules before returning them', async () => {
|
||||||
prisma.automationRule.findMany.mockResolvedValue([
|
prisma.automationRule.findMany.mockResolvedValue([
|
||||||
queenlessRule,
|
queenlessRule,
|
||||||
varroaRule,
|
varroaRule,
|
||||||
@@ -79,7 +79,7 @@ describe('AutomationService', () => {
|
|||||||
|
|
||||||
const result = await service.findAll();
|
const result = await service.findAll();
|
||||||
|
|
||||||
expect(prisma.automationRule.upsert).toHaveBeenCalledTimes(3);
|
expect(prisma.automationRule.upsert).toHaveBeenCalledTimes(6);
|
||||||
expect(result).toHaveLength(3);
|
expect(result).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,6 +188,106 @@ describe('AutomationService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('evaluateSwarmControlCheck', () => {
|
||||||
|
const swarmRule = {
|
||||||
|
id: 'rule-4',
|
||||||
|
triggerType: 'SWARM_MOOD_HIGH',
|
||||||
|
active: true,
|
||||||
|
config: {},
|
||||||
|
actionTemplate: 'Schwarmkontrolle dringend',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('creates a task when swarm mood is HIGH', async () => {
|
||||||
|
prisma.automationRule.findUnique.mockResolvedValue(swarmRule);
|
||||||
|
|
||||||
|
await service.evaluateSwarmControlCheck({
|
||||||
|
id: 'check-1',
|
||||||
|
colonyId: 'colony-1',
|
||||||
|
swarmMood: 'HIGH',
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(prisma.task.create).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not trigger for a low swarm mood', async () => {
|
||||||
|
prisma.automationRule.findUnique.mockResolvedValue(swarmRule);
|
||||||
|
|
||||||
|
await service.evaluateSwarmControlCheck({
|
||||||
|
id: 'check-2',
|
||||||
|
colonyId: 'colony-1',
|
||||||
|
swarmMood: 'LOW',
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(prisma.task.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('evaluateColonyHealth', () => {
|
||||||
|
const healthRule = {
|
||||||
|
id: 'rule-5',
|
||||||
|
triggerType: 'COLONY_HEALTH_CRITICAL',
|
||||||
|
active: true,
|
||||||
|
config: {},
|
||||||
|
actionTemplate: 'Kontrolle erforderlich',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('creates a task on the transition into CRITICAL', async () => {
|
||||||
|
prisma.automationRule.findUnique.mockResolvedValue(healthRule);
|
||||||
|
|
||||||
|
await service.evaluateColonyHealth(
|
||||||
|
{ id: 'colony-1', healthStatus: 'AVERAGE' } as never,
|
||||||
|
{ id: 'colony-1', healthStatus: 'CRITICAL' } as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(prisma.task.create).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not re-trigger when already CRITICAL before the update', async () => {
|
||||||
|
prisma.automationRule.findUnique.mockResolvedValue(healthRule);
|
||||||
|
|
||||||
|
await service.evaluateColonyHealth(
|
||||||
|
{ id: 'colony-1', healthStatus: 'CRITICAL' } as never,
|
||||||
|
{ id: 'colony-1', healthStatus: 'CRITICAL' } as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(prisma.task.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('evaluateStockItem', () => {
|
||||||
|
const stockRule = {
|
||||||
|
id: 'rule-6',
|
||||||
|
triggerType: 'STOCK_ITEM_LOW',
|
||||||
|
active: true,
|
||||||
|
config: {},
|
||||||
|
actionTemplate: 'Nachbestellung prüfen',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('creates a task when quantity drops below minThreshold', async () => {
|
||||||
|
prisma.automationRule.findUnique.mockResolvedValue(stockRule);
|
||||||
|
|
||||||
|
await service.evaluateStockItem({
|
||||||
|
id: 'item-1',
|
||||||
|
quantity: 2,
|
||||||
|
minThreshold: 5,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(prisma.task.create).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not trigger when minThreshold is not set', async () => {
|
||||||
|
prisma.automationRule.findUnique.mockResolvedValue(stockRule);
|
||||||
|
|
||||||
|
await service.evaluateStockItem({
|
||||||
|
id: 'item-2',
|
||||||
|
quantity: 2,
|
||||||
|
minThreshold: null,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(prisma.task.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('update', () => {
|
describe('update', () => {
|
||||||
it('records an audit log entry with before/after data', async () => {
|
it('records an audit log entry with before/after data', async () => {
|
||||||
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
|
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ import { WebhooksService } from '../webhooks/webhooks.service';
|
|||||||
import {
|
import {
|
||||||
AutomationRule,
|
AutomationRule,
|
||||||
AutomationTriggerType,
|
AutomationTriggerType,
|
||||||
|
Colony,
|
||||||
Inspection,
|
Inspection,
|
||||||
Prisma,
|
Prisma,
|
||||||
|
StockItem,
|
||||||
|
SwarmControlCheck,
|
||||||
|
TaskRelatedType,
|
||||||
VarroaMeasurement,
|
VarroaMeasurement,
|
||||||
} from '../../generated/prisma';
|
} from '../../generated/prisma';
|
||||||
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
|
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
|
||||||
@@ -31,6 +35,19 @@ const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
|
|||||||
config: {},
|
config: {},
|
||||||
actionTemplate: 'Fütterung erforderlich: Futterstatus kritisch.',
|
actionTemplate: 'Fütterung erforderlich: Futterstatus kritisch.',
|
||||||
},
|
},
|
||||||
|
SWARM_MOOD_HIGH: {
|
||||||
|
config: {},
|
||||||
|
actionTemplate:
|
||||||
|
'Schwarmkontrolle dringend: hohe Schwarmstimmung festgestellt.',
|
||||||
|
},
|
||||||
|
COLONY_HEALTH_CRITICAL: {
|
||||||
|
config: {},
|
||||||
|
actionTemplate: 'Kontrolle erforderlich: Gesundheitsstatus kritisch.',
|
||||||
|
},
|
||||||
|
STOCK_ITEM_LOW: {
|
||||||
|
config: {},
|
||||||
|
actionTemplate: 'Nachbestellung prüfen: Lagerartikel unter Mindestbestand.',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -118,6 +135,7 @@ export class AutomationService {
|
|||||||
) {
|
) {
|
||||||
await this.triggerAction(
|
await this.triggerAction(
|
||||||
queenlessRule,
|
queenlessRule,
|
||||||
|
'COLONY',
|
||||||
inspection.colonyId,
|
inspection.colonyId,
|
||||||
inspection.id,
|
inspection.id,
|
||||||
);
|
);
|
||||||
@@ -125,7 +143,12 @@ export class AutomationService {
|
|||||||
|
|
||||||
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
|
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
|
||||||
if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') {
|
if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') {
|
||||||
await this.triggerAction(feedRule, inspection.colonyId, inspection.id);
|
await this.triggerAction(
|
||||||
|
feedRule,
|
||||||
|
'COLONY',
|
||||||
|
inspection.colonyId,
|
||||||
|
inspection.id,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,13 +166,59 @@ export class AutomationService {
|
|||||||
const threshold = config?.thresholdMitesPerDay ?? 10;
|
const threshold = config?.thresholdMitesPerDay ?? 10;
|
||||||
const value = measurement.calculatedValue ?? measurement.miteCount;
|
const value = measurement.calculatedValue ?? measurement.miteCount;
|
||||||
if (value !== null && value >= threshold) {
|
if (value !== null && value >= threshold) {
|
||||||
await this.triggerAction(rule, measurement.colonyId);
|
await this.triggerAction(rule, 'COLONY', measurement.colonyId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async evaluateSwarmControlCheck(check: SwarmControlCheck): Promise<void> {
|
||||||
|
await this.ensureDefaults();
|
||||||
|
const rule = await this.prisma.automationRule.findUnique({
|
||||||
|
where: { triggerType: 'SWARM_MOOD_HIGH' },
|
||||||
|
});
|
||||||
|
if (!rule || !rule.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (check.swarmMood === 'HIGH') {
|
||||||
|
await this.triggerAction(rule, 'COLONY', check.colonyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fires only on the transition into CRITICAL, not on every edit while already critical. */
|
||||||
|
async evaluateColonyHealth(before: Colony, after: Colony): Promise<void> {
|
||||||
|
await this.ensureDefaults();
|
||||||
|
if (
|
||||||
|
after.healthStatus !== 'CRITICAL' ||
|
||||||
|
before.healthStatus === 'CRITICAL'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rule = await this.prisma.automationRule.findUnique({
|
||||||
|
where: { triggerType: 'COLONY_HEALTH_CRITICAL' },
|
||||||
|
});
|
||||||
|
if (!rule || !rule.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.triggerAction(rule, 'COLONY', after.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async evaluateStockItem(item: StockItem): Promise<void> {
|
||||||
|
await this.ensureDefaults();
|
||||||
|
if (item.minThreshold === null || item.quantity >= item.minThreshold) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rule = await this.prisma.automationRule.findUnique({
|
||||||
|
where: { triggerType: 'STOCK_ITEM_LOW' },
|
||||||
|
});
|
||||||
|
if (!rule || !rule.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.triggerAction(rule, 'STORAGE', item.id);
|
||||||
|
}
|
||||||
|
|
||||||
private async triggerAction(
|
private async triggerAction(
|
||||||
rule: AutomationRule,
|
rule: AutomationRule,
|
||||||
colonyId: string,
|
relatedType: TaskRelatedType,
|
||||||
|
relatedId: string,
|
||||||
sourceInspectionId?: string,
|
sourceInspectionId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe';
|
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe';
|
||||||
@@ -157,8 +226,8 @@ export class AutomationService {
|
|||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
priority: 'HIGH',
|
priority: 'HIGH',
|
||||||
relatedType: 'COLONY',
|
relatedType,
|
||||||
relatedId: colonyId,
|
relatedId,
|
||||||
sourceInspectionId,
|
sourceInspectionId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -166,14 +235,19 @@ export class AutomationService {
|
|||||||
await this.notifications.record({
|
await this.notifications.record({
|
||||||
priority: 'WARNUNG',
|
priority: 'WARNUNG',
|
||||||
message: title,
|
message: title,
|
||||||
relatedType: 'COLONY',
|
relatedType,
|
||||||
relatedId: colonyId,
|
relatedId,
|
||||||
});
|
});
|
||||||
await this.auditLog.record({
|
await this.auditLog.record({
|
||||||
entityType: 'AutomationRule',
|
entityType: 'AutomationRule',
|
||||||
entityId: rule.id,
|
entityId: rule.id,
|
||||||
action: 'CREATE',
|
action: 'CREATE',
|
||||||
afterData: { taskId: task.id, colonyId, triggerType: rule.triggerType },
|
afterData: {
|
||||||
|
taskId: task.id,
|
||||||
|
relatedType,
|
||||||
|
relatedId,
|
||||||
|
triggerType: rule.triggerType,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AutomationModule } from '../automation/automation.module';
|
||||||
import { ColoniesController } from './colonies.controller';
|
import { ColoniesController } from './colonies.controller';
|
||||||
import { ColoniesService } from './colonies.service';
|
import { ColoniesService } from './colonies.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [AutomationModule],
|
||||||
controllers: [ColoniesController],
|
controllers: [ColoniesController],
|
||||||
providers: [ColoniesService],
|
providers: [ColoniesService],
|
||||||
exports: [ColoniesService],
|
exports: [ColoniesService],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ColoniesService } from './colonies.service';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
|
import { AutomationService } from '../automation/automation.service';
|
||||||
|
|
||||||
describe('ColoniesService', () => {
|
describe('ColoniesService', () => {
|
||||||
let service: ColoniesService;
|
let service: ColoniesService;
|
||||||
@@ -55,10 +56,14 @@ describe('ColoniesService', () => {
|
|||||||
};
|
};
|
||||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||||
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const automation = {
|
||||||
|
evaluateColonyHealth: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
service = new ColoniesService(
|
service = new ColoniesService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
webhooks as unknown as WebhooksService,
|
webhooks as unknown as WebhooksService,
|
||||||
|
automation as unknown as AutomationService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
|
import { AutomationService } from '../automation/automation.service';
|
||||||
import { Colony, ColonyStatus } from '../../generated/prisma';
|
import { Colony, ColonyStatus } from '../../generated/prisma';
|
||||||
import { CreateColonyDto } from './dto/create-colony.dto';
|
import { CreateColonyDto } from './dto/create-colony.dto';
|
||||||
import { UpdateColonyDto } from './dto/update-colony.dto';
|
import { UpdateColonyDto } from './dto/update-colony.dto';
|
||||||
@@ -24,6 +25,7 @@ export class ColoniesService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
private readonly webhooks: WebhooksService,
|
private readonly webhooks: WebhooksService,
|
||||||
|
private readonly automation: AutomationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(filter: ColonyFilter = {}): Promise<Colony[]> {
|
async findAll(filter: ColonyFilter = {}): Promise<Colony[]> {
|
||||||
@@ -80,6 +82,7 @@ export class ColoniesService {
|
|||||||
beforeData: before,
|
beforeData: before,
|
||||||
afterData: colony,
|
afterData: colony,
|
||||||
});
|
});
|
||||||
|
await this.automation.evaluateColonyHealth(before, colony);
|
||||||
return colony;
|
return colony;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateLabelTemplateDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
'Etikettentext mit Platzhaltern: {{batchNumber}} {{honeyType}} {{lotNumber}} {{bottlingDate}} {{bestBeforeDate}} {{jarSize}} {{productName}}',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
content!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(10)
|
||||||
|
widthMm?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(10)
|
||||||
|
heightMm?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isDefault?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateLabelTemplateDto } from './create-label-template.dto';
|
||||||
|
|
||||||
|
export class UpdateLabelTemplateDto extends PartialType(
|
||||||
|
CreateLabelTemplateDto,
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Res,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||||
|
import { CreateLabelTemplateDto } from './dto/create-label-template.dto';
|
||||||
|
import { UpdateLabelTemplateDto } from './dto/update-label-template.dto';
|
||||||
|
import { LabelTemplatesService } from './label-templates.service';
|
||||||
|
|
||||||
|
@ApiTags('label-templates')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('label-templates')
|
||||||
|
export class LabelTemplatesController {
|
||||||
|
constructor(private readonly labelTemplatesService: LabelTemplatesService) {}
|
||||||
|
|
||||||
|
@RequirePermission('label-templates:read')
|
||||||
|
@Get()
|
||||||
|
findAll() {
|
||||||
|
return this.labelTemplatesService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('label-templates:read')
|
||||||
|
@Get(':id')
|
||||||
|
findOne(@Param('id') id: string) {
|
||||||
|
return this.labelTemplatesService.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('label-templates:create')
|
||||||
|
@Post()
|
||||||
|
create(
|
||||||
|
@Body() dto: CreateLabelTemplateDto,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.labelTemplatesService.create(dto, actor.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('label-templates:update')
|
||||||
|
@Patch(':id')
|
||||||
|
update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateLabelTemplateDto,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.labelTemplatesService.update(id, dto, actor.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('label-templates:deactivate')
|
||||||
|
@Delete(':id')
|
||||||
|
deactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
|
||||||
|
return this.labelTemplatesService.deactivate(id, actor.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('label-templates:print')
|
||||||
|
@Get(':id/print/:bottlingBatchId')
|
||||||
|
async print(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('bottlingBatchId') bottlingBatchId: string,
|
||||||
|
@Res() res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const pdf = await this.labelTemplatesService.renderLabelsPdf(
|
||||||
|
id,
|
||||||
|
bottlingBatchId,
|
||||||
|
);
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Disposition',
|
||||||
|
'attachment; filename="etiketten.pdf"',
|
||||||
|
);
|
||||||
|
res.send(pdf);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { LabelTemplatesController } from './label-templates.controller';
|
||||||
|
import { LabelTemplatesService } from './label-templates.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [LabelTemplatesController],
|
||||||
|
providers: [LabelTemplatesService],
|
||||||
|
exports: [LabelTemplatesService],
|
||||||
|
})
|
||||||
|
export class LabelTemplatesModule {}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
import { LabelTemplatesService } from './label-templates.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
|
||||||
|
describe('LabelTemplatesService', () => {
|
||||||
|
let service: LabelTemplatesService;
|
||||||
|
let tx: {
|
||||||
|
labelTemplate: {
|
||||||
|
updateMany: jest.Mock;
|
||||||
|
create: jest.Mock;
|
||||||
|
update: jest.Mock;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let prisma: {
|
||||||
|
labelTemplate: {
|
||||||
|
findMany: jest.Mock;
|
||||||
|
findUnique: jest.Mock;
|
||||||
|
update: jest.Mock;
|
||||||
|
};
|
||||||
|
bottlingBatch: { findUnique: jest.Mock };
|
||||||
|
$transaction: jest.Mock;
|
||||||
|
};
|
||||||
|
let auditLog: { record: jest.Mock };
|
||||||
|
|
||||||
|
const template = {
|
||||||
|
id: 'template-1',
|
||||||
|
name: 'Standard',
|
||||||
|
content: 'Charge {{batchNumber}} - MHD {{bestBeforeDate}}',
|
||||||
|
widthMm: 90,
|
||||||
|
heightMm: 35,
|
||||||
|
isDefault: false,
|
||||||
|
active: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tx = {
|
||||||
|
labelTemplate: {
|
||||||
|
updateMany: jest.fn().mockResolvedValue({}),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
prisma = {
|
||||||
|
labelTemplate: {
|
||||||
|
findMany: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
|
bottlingBatch: { findUnique: jest.fn() },
|
||||||
|
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
|
||||||
|
callback(tx),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
service = new LabelTemplatesService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
auditLog as unknown as AuditLogService,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findById throws NotFoundException when missing', async () => {
|
||||||
|
prisma.labelTemplate.findUnique.mockResolvedValue(null);
|
||||||
|
await expect(service.findById('missing')).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('clears other defaults first when isDefault is set', async () => {
|
||||||
|
tx.labelTemplate.create.mockResolvedValue({
|
||||||
|
...template,
|
||||||
|
isDefault: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.create(
|
||||||
|
{ name: 'Standard', content: 'x', isDefault: true },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tx.labelTemplate.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: {},
|
||||||
|
data: { isDefault: false },
|
||||||
|
});
|
||||||
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: 'CREATE' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not touch other defaults when isDefault is not set', async () => {
|
||||||
|
tx.labelTemplate.create.mockResolvedValue(template);
|
||||||
|
|
||||||
|
await service.create({ name: 'Standard', content: 'x' }, 'user-1');
|
||||||
|
|
||||||
|
expect(tx.labelTemplate.updateMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deactivate', () => {
|
||||||
|
it('sets active and isDefault to false, records DEACTIVATE', async () => {
|
||||||
|
prisma.labelTemplate.findUnique.mockResolvedValue(template);
|
||||||
|
prisma.labelTemplate.update.mockResolvedValue({
|
||||||
|
...template,
|
||||||
|
active: false,
|
||||||
|
isDefault: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.deactivate('template-1', 'user-1');
|
||||||
|
|
||||||
|
expect(result.active).toBe(false);
|
||||||
|
expect(prisma.labelTemplate.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'template-1' },
|
||||||
|
data: { active: false, isDefault: false },
|
||||||
|
});
|
||||||
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: 'DEACTIVATE' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('renderLabelsPdf', () => {
|
||||||
|
it('fills placeholders and tiles one label per jar', async () => {
|
||||||
|
prisma.labelTemplate.findUnique.mockResolvedValue(template);
|
||||||
|
prisma.bottlingBatch.findUnique.mockResolvedValue({
|
||||||
|
id: 'batch-1',
|
||||||
|
jarSize: 500,
|
||||||
|
jarCount: 2,
|
||||||
|
lotNumber: 'L-001',
|
||||||
|
date: new Date('2026-06-01T00:00:00.000Z'),
|
||||||
|
bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'),
|
||||||
|
honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' },
|
||||||
|
product: { name: 'Glas 500g' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
|
||||||
|
|
||||||
|
expect(pdf).toBeInstanceOf(Buffer);
|
||||||
|
expect(pdf.length).toBeGreaterThan(0);
|
||||||
|
// %PDF header confirms a real PDF document was produced.
|
||||||
|
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundException when the bottling batch does not exist', async () => {
|
||||||
|
prisma.labelTemplate.findUnique.mockResolvedValue(template);
|
||||||
|
prisma.bottlingBatch.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.renderLabelsPdf('template-1', 'missing'),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('embeds a QR code pointing at the Herkunft page when the template uses {{qrCode}}', async () => {
|
||||||
|
prisma.labelTemplate.findUnique.mockResolvedValue({
|
||||||
|
...template,
|
||||||
|
content: `${template.content} {{qrCode}}`,
|
||||||
|
});
|
||||||
|
prisma.bottlingBatch.findUnique.mockResolvedValue({
|
||||||
|
id: 'batch-1',
|
||||||
|
jarSize: 500,
|
||||||
|
jarCount: 1,
|
||||||
|
lotNumber: 'L-001',
|
||||||
|
date: new Date('2026-06-01T00:00:00.000Z'),
|
||||||
|
bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'),
|
||||||
|
honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' },
|
||||||
|
product: { name: 'Glas 500g' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
|
||||||
|
|
||||||
|
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
|
||||||
|
// A PDF with an embedded raster image contains an XObject /Image entry.
|
||||||
|
expect(pdf.toString('latin1')).toContain('/Image');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import PDFDocument from 'pdfkit';
|
||||||
|
import * as QRCode from 'qrcode';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { LabelTemplate } from '../../generated/prisma';
|
||||||
|
import { CreateLabelTemplateDto } from './dto/create-label-template.dto';
|
||||||
|
import { UpdateLabelTemplateDto } from './dto/update-label-template.dto';
|
||||||
|
|
||||||
|
const MM_TO_PT = 72 / 25.4;
|
||||||
|
const PAGE_MARGIN_MM = 5;
|
||||||
|
const LABEL_GAP_MM = 2;
|
||||||
|
const A4_WIDTH_MM = 210;
|
||||||
|
const A4_HEIGHT_MM = 297;
|
||||||
|
const QR_CODE_PLACEHOLDER = '{{qrCode}}';
|
||||||
|
|
||||||
|
function formatDate(date: Date): string {
|
||||||
|
return date.toLocaleDateString('de-DE');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillPlaceholders(
|
||||||
|
content: string,
|
||||||
|
values: Record<string, string>,
|
||||||
|
): string {
|
||||||
|
return content.replace(/\{\{(\w+)\}\}/g, (match, key: string) =>
|
||||||
|
key in values ? values[key] : match,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LabelTemplatesService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly auditLog: AuditLogService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findAll(): Promise<LabelTemplate[]> {
|
||||||
|
return this.prisma.labelTemplate.findMany({ orderBy: { name: 'asc' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<LabelTemplate> {
|
||||||
|
const template = await this.prisma.labelTemplate.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
if (!template) {
|
||||||
|
throw new NotFoundException('Label template not found');
|
||||||
|
}
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
dto: CreateLabelTemplateDto,
|
||||||
|
actingUserId: string,
|
||||||
|
): Promise<LabelTemplate> {
|
||||||
|
const template = await this.prisma.$transaction(async (tx) => {
|
||||||
|
if (dto.isDefault) {
|
||||||
|
await tx.labelTemplate.updateMany({
|
||||||
|
where: {},
|
||||||
|
data: { isDefault: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return tx.labelTemplate.create({ data: { ...dto } });
|
||||||
|
});
|
||||||
|
await this.auditLog.record({
|
||||||
|
entityType: 'LabelTemplate',
|
||||||
|
entityId: template.id,
|
||||||
|
action: 'CREATE',
|
||||||
|
userId: actingUserId,
|
||||||
|
afterData: template,
|
||||||
|
});
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
dto: UpdateLabelTemplateDto,
|
||||||
|
actingUserId: string,
|
||||||
|
): Promise<LabelTemplate> {
|
||||||
|
const before = await this.findById(id);
|
||||||
|
const template = await this.prisma.$transaction(async (tx) => {
|
||||||
|
if (dto.isDefault) {
|
||||||
|
await tx.labelTemplate.updateMany({
|
||||||
|
where: { id: { not: id } },
|
||||||
|
data: { isDefault: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return tx.labelTemplate.update({ where: { id }, data: { ...dto } });
|
||||||
|
});
|
||||||
|
await this.auditLog.record({
|
||||||
|
entityType: 'LabelTemplate',
|
||||||
|
entityId: id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
userId: actingUserId,
|
||||||
|
beforeData: before,
|
||||||
|
afterData: template,
|
||||||
|
});
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deactivate(id: string, actingUserId: string): Promise<LabelTemplate> {
|
||||||
|
const before = await this.findById(id);
|
||||||
|
const template = await this.prisma.labelTemplate.update({
|
||||||
|
where: { id },
|
||||||
|
data: { active: false, isDefault: false },
|
||||||
|
});
|
||||||
|
await this.auditLog.record({
|
||||||
|
entityType: 'LabelTemplate',
|
||||||
|
entityId: id,
|
||||||
|
action: 'DEACTIVATE',
|
||||||
|
userId: actingUserId,
|
||||||
|
beforeData: before,
|
||||||
|
afterData: template,
|
||||||
|
});
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
async renderLabelsPdf(
|
||||||
|
templateId: string,
|
||||||
|
bottlingBatchId: string,
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const template = await this.findById(templateId);
|
||||||
|
const bottlingBatch = await this.prisma.bottlingBatch.findUnique({
|
||||||
|
where: { id: bottlingBatchId },
|
||||||
|
include: { honeyBatch: true, product: true },
|
||||||
|
});
|
||||||
|
if (!bottlingBatch) {
|
||||||
|
throw new NotFoundException('Bottling batch not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const values: Record<string, string> = {
|
||||||
|
batchNumber: bottlingBatch.honeyBatch.batchNumber,
|
||||||
|
honeyType: bottlingBatch.honeyBatch.honeyType ?? '',
|
||||||
|
lotNumber: bottlingBatch.lotNumber,
|
||||||
|
bottlingDate: formatDate(bottlingBatch.date),
|
||||||
|
bestBeforeDate: formatDate(bottlingBatch.bestBeforeDate),
|
||||||
|
jarSize: `${bottlingBatch.jarSize} ml`,
|
||||||
|
productName: bottlingBatch.product?.name ?? '',
|
||||||
|
};
|
||||||
|
const labelText = fillPlaceholders(template.content, values);
|
||||||
|
|
||||||
|
// The QR code is generated once (not per copy) - it always points at the
|
||||||
|
// public Herkunft/traceability page for this batch, which already shows
|
||||||
|
// honey type, dates, and origin, so the label itself only needs to
|
||||||
|
// reference it rather than duplicate that information.
|
||||||
|
let qrCodeBuffer: Buffer | null = null;
|
||||||
|
if (labelText.includes(QR_CODE_PLACEHOLDER)) {
|
||||||
|
const frontendOrigin =
|
||||||
|
process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000';
|
||||||
|
const herkunftUrl = `${frontendOrigin}/herkunft/${encodeURIComponent(bottlingBatch.lotNumber)}`;
|
||||||
|
qrCodeBuffer = await QRCode.toBuffer(herkunftUrl, {
|
||||||
|
margin: 1,
|
||||||
|
width: 300,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.renderPdf(
|
||||||
|
labelText,
|
||||||
|
template.widthMm,
|
||||||
|
template.heightMm,
|
||||||
|
bottlingBatch.jarCount,
|
||||||
|
qrCodeBuffer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderPdf(
|
||||||
|
labelText: string,
|
||||||
|
widthMm: number,
|
||||||
|
heightMm: number,
|
||||||
|
copies: number,
|
||||||
|
qrCodeBuffer: Buffer | null,
|
||||||
|
): Promise<Buffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const doc = new PDFDocument({ size: 'A4', margin: 0 });
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
doc.on('error', (error: Error) => reject(error));
|
||||||
|
|
||||||
|
const usableWidthMm = A4_WIDTH_MM - 2 * PAGE_MARGIN_MM;
|
||||||
|
const usableHeightMm = A4_HEIGHT_MM - 2 * PAGE_MARGIN_MM;
|
||||||
|
const columns = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor((usableWidthMm + LABEL_GAP_MM) / (widthMm + LABEL_GAP_MM)),
|
||||||
|
);
|
||||||
|
const rows = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor((usableHeightMm + LABEL_GAP_MM) / (heightMm + LABEL_GAP_MM)),
|
||||||
|
);
|
||||||
|
const perPage = columns * rows;
|
||||||
|
|
||||||
|
for (let index = 0; index < copies; index += 1) {
|
||||||
|
const onPage = index % perPage;
|
||||||
|
if (index > 0 && onPage === 0) {
|
||||||
|
doc.addPage({ size: 'A4', margin: 0 });
|
||||||
|
}
|
||||||
|
const column = onPage % columns;
|
||||||
|
const row = Math.floor(onPage / columns);
|
||||||
|
const xMm = PAGE_MARGIN_MM + column * (widthMm + LABEL_GAP_MM);
|
||||||
|
const yMm = PAGE_MARGIN_MM + row * (heightMm + LABEL_GAP_MM);
|
||||||
|
const x = xMm * MM_TO_PT;
|
||||||
|
const y = yMm * MM_TO_PT;
|
||||||
|
const w = widthMm * MM_TO_PT;
|
||||||
|
const h = heightMm * MM_TO_PT;
|
||||||
|
|
||||||
|
doc.rect(x, y, w, h).dash(2, { space: 2 }).stroke('#999999').undash();
|
||||||
|
|
||||||
|
if (qrCodeBuffer) {
|
||||||
|
const qrSide = Math.min(w, h) - 8;
|
||||||
|
const qrX = x + w - qrSide - 4;
|
||||||
|
const qrY = y + (h - qrSide) / 2;
|
||||||
|
doc.image(qrCodeBuffer, qrX, qrY, { width: qrSide, height: qrSide });
|
||||||
|
const textWidth = qrX - 4 - (x + 4);
|
||||||
|
doc
|
||||||
|
.fontSize(9)
|
||||||
|
.fillColor('#000000')
|
||||||
|
.text(
|
||||||
|
labelText.replace(QR_CODE_PLACEHOLDER, '').trim(),
|
||||||
|
x + 4,
|
||||||
|
y + 4,
|
||||||
|
{
|
||||||
|
width: Math.max(textWidth, 0),
|
||||||
|
height: h - 8,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
doc
|
||||||
|
.fontSize(9)
|
||||||
|
.fillColor('#000000')
|
||||||
|
.text(labelText, x + 4, y + 4, { width: w - 8, height: h - 8 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class DeactivateAccountDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
currentPassword!: string;
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
|||||||
import { SENSITIVE_ACTION_THROTTLE } from '../auth/util/throttle';
|
import { SENSITIVE_ACTION_THROTTLE } from '../auth/util/throttle';
|
||||||
import { isAllowedAvatarMimeType } from './avatar-mime-types';
|
import { isAllowedAvatarMimeType } from './avatar-mime-types';
|
||||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||||
|
import { DeactivateAccountDto } from './dto/deactivate-account.dto';
|
||||||
import { DisableMfaDto } from './dto/disable-mfa.dto';
|
import { DisableMfaDto } from './dto/disable-mfa.dto';
|
||||||
import { MfaConfirmDto } from './dto/mfa-confirm.dto';
|
import { MfaConfirmDto } from './dto/mfa-confirm.dto';
|
||||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||||
@@ -64,6 +65,16 @@ export class ProfileController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Throttle(SENSITIVE_ACTION_THROTTLE)
|
||||||
|
@Delete()
|
||||||
|
@HttpCode(204)
|
||||||
|
deactivateAccount(
|
||||||
|
@Body() dto: DeactivateAccountDto,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.profileService.deactivateAccount(actor.id, dto.currentPassword);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('avatar')
|
@Post('avatar')
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { MfaModule } from '../auth/mfa/mfa.module';
|
import { MfaModule } from '../auth/mfa/mfa.module';
|
||||||
|
import { UsersModule } from '../users/users.module';
|
||||||
import { ProfileController } from './profile.controller';
|
import { ProfileController } from './profile.controller';
|
||||||
import { ProfileService } from './profile.service';
|
import { ProfileService } from './profile.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [MfaModule],
|
imports: [MfaModule, UsersModule],
|
||||||
controllers: [ProfileController],
|
controllers: [ProfileController],
|
||||||
providers: [ProfileService],
|
providers: [ProfileService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { MfaService } from '../auth/mfa/mfa.service';
|
import { MfaService } from '../auth/mfa/mfa.service';
|
||||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
jest.mock('node:fs/promises');
|
jest.mock('node:fs/promises');
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ describe('ProfileService', () => {
|
|||||||
confirmEnrollment: jest.Mock;
|
confirmEnrollment: jest.Mock;
|
||||||
};
|
};
|
||||||
let permissionResolver: { invalidateUser: jest.Mock };
|
let permissionResolver: { invalidateUser: jest.Mock };
|
||||||
|
let usersService: { deactivate: jest.Mock };
|
||||||
|
|
||||||
const baseUser = {
|
const baseUser = {
|
||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
@@ -52,11 +54,13 @@ describe('ProfileService', () => {
|
|||||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||||
mfaService = { startEnrollment: jest.fn(), confirmEnrollment: jest.fn() };
|
mfaService = { startEnrollment: jest.fn(), confirmEnrollment: jest.fn() };
|
||||||
permissionResolver = { invalidateUser: jest.fn() };
|
permissionResolver = { invalidateUser: jest.fn() };
|
||||||
|
usersService = { deactivate: jest.fn().mockResolvedValue(undefined) };
|
||||||
service = new ProfileService(
|
service = new ProfileService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
mfaService as unknown as MfaService,
|
mfaService as unknown as MfaService,
|
||||||
permissionResolver as unknown as PermissionResolverService,
|
permissionResolver as unknown as PermissionResolverService,
|
||||||
|
usersService as unknown as UsersService,
|
||||||
);
|
);
|
||||||
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
|
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
|
||||||
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
|
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
|
||||||
@@ -155,6 +159,24 @@ describe('ProfileService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('deactivateAccount', () => {
|
||||||
|
it('throws UnauthorizedException when the current password is wrong', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue(baseUser);
|
||||||
|
await expect(
|
||||||
|
service.deactivateAccount('user-1', 'wrong-password'),
|
||||||
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
expect(usersService.deactivate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deactivates the user via UsersService when the password matches', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue(baseUser);
|
||||||
|
|
||||||
|
await service.deactivateAccount('user-1', 'correct-password');
|
||||||
|
|
||||||
|
expect(usersService.deactivate).toHaveBeenCalledWith('user-1', 'user-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('uploadAvatar', () => {
|
describe('uploadAvatar', () => {
|
||||||
const file = {
|
const file = {
|
||||||
buffer: Buffer.from('binary'),
|
buffer: Buffer.from('binary'),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { MfaService, MfaEnrollment } from '../auth/mfa/mfa.service';
|
import { MfaService, MfaEnrollment } from '../auth/mfa/mfa.service';
|
||||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
import {
|
import {
|
||||||
ALLOWED_AVATAR_MIME_TYPES,
|
ALLOWED_AVATAR_MIME_TYPES,
|
||||||
mimeTypeForExtension,
|
mimeTypeForExtension,
|
||||||
@@ -41,6 +42,7 @@ export class ProfileService {
|
|||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
private readonly mfaService: MfaService,
|
private readonly mfaService: MfaService,
|
||||||
private readonly permissionResolver: PermissionResolverService,
|
private readonly permissionResolver: PermissionResolverService,
|
||||||
|
private readonly usersService: UsersService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async requireUser(userId: string) {
|
private async requireUser(userId: string) {
|
||||||
@@ -124,6 +126,21 @@ export class ProfileService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deactivateAccount(
|
||||||
|
userId: string,
|
||||||
|
currentPassword: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const user = await this.requireUser(userId);
|
||||||
|
const currentMatches = await bcrypt.compare(
|
||||||
|
currentPassword,
|
||||||
|
user.passwordHash,
|
||||||
|
);
|
||||||
|
if (!currentMatches) {
|
||||||
|
throw new UnauthorizedException('Aktuelles Passwort ist falsch');
|
||||||
|
}
|
||||||
|
await this.usersService.deactivate(userId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
async uploadAvatar(userId: string, file: Express.Multer.File): Promise<void> {
|
async uploadAvatar(userId: string, file: Express.Multer.File): Promise<void> {
|
||||||
if (!(file.mimetype in ALLOWED_AVATAR_MIME_TYPES)) {
|
if (!(file.mimetype in ALLOWED_AVATAR_MIME_TYPES)) {
|
||||||
throw new BadRequestException('Nicht unterstützter Dateityp');
|
throw new BadRequestException('Nicht unterstützter Dateityp');
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AutomationModule } from '../automation/automation.module';
|
||||||
import { StorageController } from './storage.controller';
|
import { StorageController } from './storage.controller';
|
||||||
import { StorageService } from './storage.service';
|
import { StorageService } from './storage.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [AutomationModule],
|
||||||
controllers: [StorageController],
|
controllers: [StorageController],
|
||||||
providers: [StorageService],
|
providers: [StorageService],
|
||||||
exports: [StorageService],
|
exports: [StorageService],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { StorageService } from './storage.service';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
|
import { AutomationService } from '../automation/automation.service';
|
||||||
|
|
||||||
describe('StorageService', () => {
|
describe('StorageService', () => {
|
||||||
let service: StorageService;
|
let service: StorageService;
|
||||||
@@ -47,10 +48,14 @@ describe('StorageService', () => {
|
|||||||
};
|
};
|
||||||
auditLog = { record: jest.fn() };
|
auditLog = { record: jest.fn() };
|
||||||
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const automation = {
|
||||||
|
evaluateStockItem: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
service = new StorageService(
|
service = new StorageService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
webhooks as unknown as WebhooksService,
|
webhooks as unknown as WebhooksService,
|
||||||
|
automation as unknown as AutomationService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
|
import { AutomationService } from '../automation/automation.service';
|
||||||
import { StockItem, StorageContainer } from '../../generated/prisma';
|
import { StockItem, StorageContainer } from '../../generated/prisma';
|
||||||
import { CreateStorageContainerDto } from './dto/create-storage-container.dto';
|
import { CreateStorageContainerDto } from './dto/create-storage-container.dto';
|
||||||
import { UpdateStorageContainerDto } from './dto/update-storage-container.dto';
|
import { UpdateStorageContainerDto } from './dto/update-storage-container.dto';
|
||||||
@@ -15,6 +16,7 @@ export class StorageService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
private readonly webhooks: WebhooksService,
|
private readonly webhooks: WebhooksService,
|
||||||
|
private readonly automation: AutomationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAllContainers(): Promise<StorageContainer[]> {
|
async findAllContainers(): Promise<StorageContainer[]> {
|
||||||
@@ -149,6 +151,7 @@ export class StorageService {
|
|||||||
});
|
});
|
||||||
const stockItem = await this.findStockItemById(id);
|
const stockItem = await this.findStockItemById(id);
|
||||||
await this.webhooks.dispatch('product.stock_changed', stockItem);
|
await this.webhooks.dispatch('product.stock_changed', stockItem);
|
||||||
|
await this.automation.evaluateStockItem(stockItem);
|
||||||
return stockItem;
|
return stockItem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AutomationModule } from '../automation/automation.module';
|
||||||
import { SwarmControlController } from './swarm-control.controller';
|
import { SwarmControlController } from './swarm-control.controller';
|
||||||
import { ColonySwarmControlChecksController } from './colony-swarm-control-checks.controller';
|
import { ColonySwarmControlChecksController } from './colony-swarm-control-checks.controller';
|
||||||
import { SwarmControlService } from './swarm-control.service';
|
import { SwarmControlService } from './swarm-control.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [AutomationModule],
|
||||||
controllers: [SwarmControlController, ColonySwarmControlChecksController],
|
controllers: [SwarmControlController, ColonySwarmControlChecksController],
|
||||||
providers: [SwarmControlService],
|
providers: [SwarmControlService],
|
||||||
exports: [SwarmControlService],
|
exports: [SwarmControlService],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common';
|
|||||||
import { SwarmControlService } from './swarm-control.service';
|
import { SwarmControlService } from './swarm-control.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { AutomationService } from '../automation/automation.service';
|
||||||
|
|
||||||
describe('SwarmControlService', () => {
|
describe('SwarmControlService', () => {
|
||||||
let service: SwarmControlService;
|
let service: SwarmControlService;
|
||||||
@@ -31,9 +32,13 @@ describe('SwarmControlService', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
auditLog = { record: jest.fn() };
|
auditLog = { record: jest.fn() };
|
||||||
|
const automation = {
|
||||||
|
evaluateSwarmControlCheck: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
service = new SwarmControlService(
|
service = new SwarmControlService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
|
automation as unknown as AutomationService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { AutomationService } from '../automation/automation.service';
|
||||||
import { SwarmControlCheck } from '../../generated/prisma';
|
import { SwarmControlCheck } from '../../generated/prisma';
|
||||||
import { CreateSwarmControlCheckDto } from './dto/create-swarm-control-check.dto';
|
import { CreateSwarmControlCheckDto } from './dto/create-swarm-control-check.dto';
|
||||||
import { UpdateSwarmControlCheckDto } from './dto/update-swarm-control-check.dto';
|
import { UpdateSwarmControlCheckDto } from './dto/update-swarm-control-check.dto';
|
||||||
@@ -10,6 +11,7 @@ export class SwarmControlService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
|
private readonly automation: AutomationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(): Promise<SwarmControlCheck[]> {
|
async findAll(): Promise<SwarmControlCheck[]> {
|
||||||
@@ -56,6 +58,7 @@ export class SwarmControlService {
|
|||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
afterData: check,
|
afterData: check,
|
||||||
});
|
});
|
||||||
|
await this.automation.evaluateSwarmControlCheck(check);
|
||||||
return check;
|
return check;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +86,7 @@ export class SwarmControlService {
|
|||||||
beforeData: before,
|
beforeData: before,
|
||||||
afterData: check,
|
afterData: check,
|
||||||
});
|
});
|
||||||
|
await this.automation.evaluateSwarmControlCheck(check);
|
||||||
return check;
|
return check;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,7 @@ const ALLOWLIST = new Set([
|
|||||||
'AuthController.me',
|
'AuthController.me',
|
||||||
'ProfileController.updateProfile',
|
'ProfileController.updateProfile',
|
||||||
'ProfileController.changePassword',
|
'ProfileController.changePassword',
|
||||||
|
'ProfileController.deactivateAccount',
|
||||||
'ProfileController.uploadAvatar',
|
'ProfileController.uploadAvatar',
|
||||||
'ProfileController.deleteAvatar',
|
'ProfileController.deleteAvatar',
|
||||||
'ProfileController.getAvatar',
|
'ProfileController.getAvatar',
|
||||||
|
|||||||
@@ -8,5 +8,7 @@
|
|||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
"transformIgnorePatterns": ["node_modules/(?!(otplib|@otplib|@scure|@noble)/)"]
|
"transformIgnorePatterns": [
|
||||||
|
"node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose)/)"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,7 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="max-w-lg">
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -40,12 +40,9 @@ export default function ApiaryDetailPage() {
|
|||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<QrCodeDialog entityType="apiaries" id={apiary.id} />
|
<QrCodeDialog entityType="apiaries" id={apiary.id} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
<ApiaryEditCard key={apiary.id} apiary={apiary} />
|
<ApiaryEditCard key={apiary.id} apiary={apiary} />
|
||||||
<WeatherCard
|
|
||||||
apiaryId={apiary.id}
|
|
||||||
hasCoordinates={apiary.latitude !== null && apiary.longitude !== null}
|
|
||||||
/>
|
|
||||||
<DocumentUploadCard entityType="APIARY" entityId={apiary.id} />
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
nativeButton={false}
|
nativeButton={false}
|
||||||
@@ -63,6 +60,15 @@ export default function ApiaryDetailPage() {
|
|||||||
: `Jahresbericht ${CURRENT_YEAR}`}
|
: `Jahresbericht ${CURRENT_YEAR}`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<WeatherCard
|
||||||
|
apiaryId={apiary.id}
|
||||||
|
hasCoordinates={apiary.latitude !== null && apiary.longitude !== null}
|
||||||
|
/>
|
||||||
|
<DocumentUploadCard entityType="APIARY" entityId={apiary.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ import { fetchBottlingBatch } from "@/lib/bottling/api";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
||||||
import { BottlingEditDialog } from "./bottling-edit-dialog";
|
import { BottlingEditDialog } from "./bottling-edit-dialog";
|
||||||
|
import { PrintLabelsCard } from "./print-labels-card";
|
||||||
|
|
||||||
export default function BottlingBatchDetailPage() {
|
export default function BottlingBatchDetailPage() {
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
@@ -29,7 +30,8 @@ export default function BottlingBatchDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
<Card className="max-w-lg">
|
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-2.5">
|
<CardHeader className="flex flex-row items-center justify-between gap-2.5">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<span
|
<span
|
||||||
@@ -73,6 +75,8 @@ export default function BottlingBatchDetailPage() {
|
|||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<PrintLabelsCard bottlingBatchId={bottlingBatch.id} />
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Printer, Tag } from "lucide-react";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { fetchLabelTemplates, printLabels } from "@/lib/label-templates/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
export function PrintLabelsCard({ bottlingBatchId }: { bottlingBatchId: string }) {
|
||||||
|
const [templateId, setTemplateId] = useState("");
|
||||||
|
const { data: templates } = useQuery({
|
||||||
|
queryKey: ["label-templates"],
|
||||||
|
queryFn: fetchLabelTemplates,
|
||||||
|
});
|
||||||
|
const activeTemplates = (templates ?? []).filter((template) => template.active);
|
||||||
|
const preferredTemplateId =
|
||||||
|
activeTemplates.find((template) => template.isDefault)?.id ?? activeTemplates[0]?.id ?? "";
|
||||||
|
const effectiveTemplateId = templateId || preferredTemplateId;
|
||||||
|
const selectedTemplate = activeTemplates.find(
|
||||||
|
(template) => template.id === effectiveTemplateId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () => printLabels(effectiveTemplateId, bottlingBatchId),
|
||||||
|
onError: () => toast.error("Etiketten konnten nicht erstellt werden"),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Tag className="size-4.5" />
|
||||||
|
Etiketten drucken
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
{activeTemplates.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Noch keine Etikettenvorlage angelegt. Unter Einstellungen → Etikettenvorlagen eine
|
||||||
|
Vorlage erstellen.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Select
|
||||||
|
value={effectiveTemplateId}
|
||||||
|
onValueChange={(value) => setTemplateId(value ?? "")}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Vorlage wählen">{selectedTemplate?.name}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{activeTemplates.map((template) => (
|
||||||
|
<SelectItem key={template.id} value={template.id}>
|
||||||
|
{template.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={!effectiveTemplateId || mutation.isPending}
|
||||||
|
onClick={() => mutation.mutate()}
|
||||||
|
>
|
||||||
|
<Printer className="size-4" />
|
||||||
|
{mutation.isPending ? "Wird erstellt…" : "Etiketten als PDF"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -157,7 +157,7 @@ function SplitDialog({ colony }: { colony: Colony }) {
|
|||||||
onValueChange={(value) => setColonyType(value as ColonyType)}
|
onValueChange={(value) => setColonyType(value as ColonyType)}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="split-type">
|
<SelectTrigger id="split-type">
|
||||||
<SelectValue />
|
<SelectValue>{COLONY_TYPE_LABELS[colonyType]}</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{COLONY_TYPES.map((type) => (
|
{COLONY_TYPES.map((type) => (
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="max-w-lg">
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<span
|
<span
|
||||||
@@ -135,7 +135,7 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
|
|||||||
onValueChange={(value) => setColonyType(value as ColonyType)}
|
onValueChange={(value) => setColonyType(value as ColonyType)}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="colony-type">
|
<SelectTrigger id="colony-type">
|
||||||
<SelectValue />
|
<SelectValue>{COLONY_TYPE_LABELS[colonyType]}</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{COLONY_TYPES.map((type) => (
|
{COLONY_TYPES.map((type) => (
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function ColonyFeedingsCard({ colonyId }: { colonyId: string }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="max-w-lg">
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle>Fütterung</CardTitle>
|
<CardTitle>Fütterung</CardTitle>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function ColonyHistoryCard({ colonyId }: { colonyId: string }) {
|
|||||||
history.lineageAsResult.length > 0;
|
history.lineageAsResult.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="max-w-lg">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Historie</CardTitle>
|
<CardTitle>Historie</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function ColonyInspectionsCard({ colonyId }: { colonyId: string }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="max-w-lg">
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle>Durchsichten</CardTitle>
|
<CardTitle>Durchsichten</CardTitle>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function ColonySwarmControlCard({ colonyId }: { colonyId: string }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="max-w-lg">
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle>Schwarmkontrolle</CardTitle>
|
<CardTitle>Schwarmkontrolle</CardTitle>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function ColonyTreatmentsCard({ colonyId }: { colonyId: string }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="max-w-lg">
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle>Behandlungen</CardTitle>
|
<CardTitle>Behandlungen</CardTitle>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {
|
|||||||
.filter((value): value is number => value !== null);
|
.filter((value): value is number => value !== null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="max-w-lg">
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle>Varroa</CardTitle>
|
<CardTitle>Varroa</CardTitle>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import { Pencil } from "lucide-react";
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { updateInspection } from "@/lib/inspections/api";
|
import { updateInspection } from "@/lib/inspections/api";
|
||||||
import { Inspection } from "@/lib/inspections/types";
|
import { Inspection } from "@/lib/inspections/types";
|
||||||
|
|
||||||
|
const FEED_STATUS_LABELS: Record<string, string> = {
|
||||||
|
ausreichend: "Ausreichend",
|
||||||
|
niedrig: "Niedrig",
|
||||||
|
kritisch: "Kritisch",
|
||||||
|
};
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -125,7 +131,7 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
|
|||||||
onValueChange={(value) => setFeedStatus((value ?? "ausreichend") as string)}
|
onValueChange={(value) => setFeedStatus((value ?? "ausreichend") as string)}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="inspection-edit-feed" className="w-full">
|
<SelectTrigger id="inspection-edit-feed" className="w-full">
|
||||||
<SelectValue />
|
<SelectValue>{FEED_STATUS_LABELS[feedStatus] ?? feedStatus}</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="ausreichend">Ausreichend</SelectItem>
|
<SelectItem value="ausreichend">Ausreichend</SelectItem>
|
||||||
|
|||||||
@@ -54,15 +54,23 @@ export default function ColonyDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<QrCodeDialog entityType="colonies" id={colony.id} />
|
<QrCodeDialog entityType="colonies" id={colony.id} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-3">
|
||||||
|
<div className="flex flex-col gap-4 lg:col-span-1">
|
||||||
<ColonyEditCard key={colony.id} colony={colony} />
|
<ColonyEditCard key={colony.id} colony={colony} />
|
||||||
<ColonyActions colony={colony} />
|
<ColonyActions colony={colony} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4 lg:col-span-2">
|
||||||
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||||
<ColonyInspectionsCard colonyId={colony.id} />
|
<ColonyInspectionsCard colonyId={colony.id} />
|
||||||
<ColonyVarroaCard colonyId={colony.id} />
|
<ColonyVarroaCard colonyId={colony.id} />
|
||||||
<ColonySwarmControlCard colonyId={colony.id} />
|
<ColonySwarmControlCard colonyId={colony.id} />
|
||||||
<ColonyTreatmentsCard colonyId={colony.id} />
|
<ColonyTreatmentsCard colonyId={colony.id} />
|
||||||
<ColonyFeedingsCard colonyId={colony.id} />
|
<ColonyFeedingsCard colonyId={colony.id} />
|
||||||
<ColonyHistoryCard colonyId={colony.id} />
|
|
||||||
<DocumentUploadCard entityType="COLONY" entityId={colony.id} />
|
<DocumentUploadCard entityType="COLONY" entityId={colony.id} />
|
||||||
|
</div>
|
||||||
|
<ColonyHistoryCard colonyId={colony.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -101,7 +101,7 @@ export default function NewColonyPage() {
|
|||||||
onValueChange={(value) => setColonyType(value as ColonyType)}
|
onValueChange={(value) => setColonyType(value as ColonyType)}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="colony-type">
|
<SelectTrigger id="colony-type">
|
||||||
<SelectValue />
|
<SelectValue>{COLONY_TYPE_LABELS[colonyType]}</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{COLONY_TYPES.map((type) => (
|
{COLONY_TYPES.map((type) => (
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function HoneyBatchStorageCard({ honeyBatch }: { honeyBatch: HoneyBatch }
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="max-w-lg">
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle>Lagerbehälter</CardTitle>
|
<CardTitle>Lagerbehälter</CardTitle>
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ export default function HoneyBatchDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
<Card className="max-w-lg">
|
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-3">
|
||||||
|
<div className="flex flex-col gap-4 lg:col-span-1">
|
||||||
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<span
|
<span
|
||||||
@@ -80,8 +82,12 @@ export default function HoneyBatchDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<HoneyBatchStorageCard honeyBatch={honeyBatch} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card className="max-w-lg">
|
<div className="flex flex-col gap-4 lg:col-span-2">
|
||||||
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||||
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle>Honigmessungen</CardTitle>
|
<CardTitle>Honigmessungen</CardTitle>
|
||||||
<AddMeasurementDialog honeyBatchId={honeyBatch.id} />
|
<AddMeasurementDialog honeyBatchId={honeyBatch.id} />
|
||||||
@@ -100,13 +106,18 @@ export default function HoneyBatchDetailPage() {
|
|||||||
{new Date(measurement.measuredAt).toLocaleDateString("de-DE")} ·{" "}
|
{new Date(measurement.measuredAt).toLocaleDateString("de-DE")} ·{" "}
|
||||||
{measurement.waterContent}% Wassergehalt
|
{measurement.waterContent}% Wassergehalt
|
||||||
</p>
|
</p>
|
||||||
{measurement.device ? <p className="text-muted-foreground">{measurement.device}</p> : null}
|
{measurement.device ? (
|
||||||
|
<p className="text-muted-foreground">{measurement.device}</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="max-w-lg">
|
<DocumentUploadCard entityType="HONEY_BATCH" entityId={honeyBatch.id} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle>Rückverfolgbarkeit</CardTitle>
|
<CardTitle>Rückverfolgbarkeit</CardTitle>
|
||||||
<Button
|
<Button
|
||||||
@@ -133,7 +144,7 @@ export default function HoneyBatchDetailPage() {
|
|||||||
honeyBatchId={honeyBatch.id}
|
honeyBatchId={honeyBatch.id}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
{traceability.harvests.map((harvest) => (
|
{traceability.harvests.map((harvest) => (
|
||||||
<Link
|
<Link
|
||||||
key={harvest.id}
|
key={harvest.id}
|
||||||
@@ -153,9 +164,8 @@ export default function HoneyBatchDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
<HoneyBatchStorageCard honeyBatch={honeyBatch} />
|
</div>
|
||||||
<DocumentUploadCard entityType="HONEY_BATCH" entityId={honeyBatch.id} />
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,12 @@ import {
|
|||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
|
const FEED_STATUS_LABELS = {
|
||||||
|
ausreichend: "Ausreichend",
|
||||||
|
niedrig: "Niedrig",
|
||||||
|
kritisch: "Kritisch",
|
||||||
|
} as const;
|
||||||
|
|
||||||
function todayIsoDate(): string {
|
function todayIsoDate(): string {
|
||||||
return new Date().toISOString().slice(0, 10);
|
return new Date().toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
@@ -191,7 +197,7 @@ export default function NewInspectionPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="inspection-feed-status" className="h-11 w-full">
|
<SelectTrigger id="inspection-feed-status" className="h-11 w-full">
|
||||||
<SelectValue />
|
<SelectValue>{FEED_STATUS_LABELS[feedStatus]}</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="ausreichend">Ausreichend</SelectItem>
|
<SelectItem value="ausreichend">Ausreichend</SelectItem>
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
|
import { deactivateAccount } from "@/lib/profile/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
function DeactivateAccountDialog() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { logout } = useAuth();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () => deactivateAccount(currentPassword),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success("Konto deaktiviert");
|
||||||
|
await logout();
|
||||||
|
router.push("/login");
|
||||||
|
},
|
||||||
|
onError: () => setError("Passwort ist falsch oder das Konto kann nicht deaktiviert werden."),
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
setOpen(false);
|
||||||
|
setCurrentPassword("");
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
mutation.mutate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(next) => (next ? undefined : handleClose())}>
|
||||||
|
<Button type="button" variant="destructive" onClick={() => setOpen(true)}>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
Konto löschen
|
||||||
|
</Button>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Konto löschen</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Dein Konto wird deaktiviert und du wirst abgemeldet. Die Historie deiner bisherigen
|
||||||
|
Einträge bleibt erhalten. Zur Bestätigung aktuelles Passwort eingeben.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="deactivate-account-password">Passwort</Label>
|
||||||
|
<Input
|
||||||
|
id="deactivate-account-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={handleClose}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="destructive" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? "Wird deaktiviert…" : "Konto löschen"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DangerZoneSection() {
|
||||||
|
return (
|
||||||
|
<Card className="border-destructive/40">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Konto löschen</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Unwiderruflich für dich: Danach kannst du dich nicht mehr anmelden.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DeactivateAccountDialog />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,10 @@ import type { ReactElement } from "react";
|
|||||||
import ProfilePage from "./page";
|
import ProfilePage from "./page";
|
||||||
import { updateProfile, changePassword, uploadAvatar } from "@/lib/profile/api";
|
import { updateProfile, changePassword, uploadAvatar } from "@/lib/profile/api";
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({ push: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/profile/api", () => ({
|
vi.mock("@/lib/profile/api", () => ({
|
||||||
updateProfile: vi.fn(),
|
updateProfile: vi.fn(),
|
||||||
changePassword: vi.fn(),
|
changePassword: vi.fn(),
|
||||||
@@ -15,6 +19,7 @@ vi.mock("@/lib/profile/api", () => ({
|
|||||||
enrollMfa: vi.fn(),
|
enrollMfa: vi.fn(),
|
||||||
confirmMfaEnrollment: vi.fn(),
|
confirmMfaEnrollment: vi.fn(),
|
||||||
disableMfa: vi.fn(),
|
disableMfa: vi.fn(),
|
||||||
|
deactivateAccount: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const refreshUserMock = vi.fn();
|
const refreshUserMock = vi.fn();
|
||||||
@@ -29,7 +34,7 @@ const mockUser = {
|
|||||||
hasAvatar: false,
|
hasAvatar: false,
|
||||||
};
|
};
|
||||||
vi.mock("@/lib/auth/auth-context", () => ({
|
vi.mock("@/lib/auth/auth-context", () => ({
|
||||||
useAuth: () => ({ user: mockUser, refreshUser: refreshUserMock }),
|
useAuth: () => ({ user: mockUser, refreshUser: refreshUserMock, logout: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function renderWithClient(ui: ReactElement) {
|
function renderWithClient(ui: ReactElement) {
|
||||||
@@ -53,13 +58,13 @@ describe("ProfilePage", () => {
|
|||||||
});
|
});
|
||||||
renderWithClient(<ProfilePage />);
|
renderWithClient(<ProfilePage />);
|
||||||
|
|
||||||
const nameInput = screen.getByLabelText("Name");
|
const lastNameInput = screen.getByLabelText("Nachname");
|
||||||
await userEvent.clear(nameInput);
|
await userEvent.clear(lastNameInput);
|
||||||
await userEvent.type(nameInput, "New Name");
|
await userEvent.type(lastNameInput, "Name");
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||||
|
|
||||||
expect(updateProfile).toHaveBeenCalledWith({
|
expect(updateProfile).toHaveBeenCalledWith({
|
||||||
name: "New Name",
|
name: "Existing Name",
|
||||||
email: "[email protected]",
|
email: "[email protected]",
|
||||||
});
|
});
|
||||||
await vi.waitFor(() => expect(refreshUserMock).toHaveBeenCalled());
|
await vi.waitFor(() => expect(refreshUserMock).toHaveBeenCalled());
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "@/lib/profile/api";
|
} from "@/lib/profile/api";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { UserAvatar } from "@/components/user-avatar";
|
import { UserAvatar } from "@/components/user-avatar";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -24,6 +25,12 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { MfaSection } from "./mfa-section";
|
import { MfaSection } from "./mfa-section";
|
||||||
|
import { DangerZoneSection } from "./danger-zone-section";
|
||||||
|
|
||||||
|
function splitName(fullName: string): { firstName: string; lastName: string } {
|
||||||
|
const [firstName, ...rest] = fullName.trim().split(/\s+/);
|
||||||
|
return { firstName: firstName ?? "", lastName: rest.join(" ") };
|
||||||
|
}
|
||||||
|
|
||||||
function AvatarSection() {
|
function AvatarSection() {
|
||||||
const { user, refreshUser } = useAuth();
|
const { user, refreshUser } = useAuth();
|
||||||
@@ -100,14 +107,21 @@ function AvatarSection() {
|
|||||||
|
|
||||||
function ProfileInfoSection() {
|
function ProfileInfoSection() {
|
||||||
const { user, refreshUser } = useAuth();
|
const { user, refreshUser } = useAuth();
|
||||||
const [name, setName] = useState(user?.name ?? "");
|
const initialName = splitName(user?.name ?? "");
|
||||||
|
const [firstName, setFirstName] = useState(initialName.firstName);
|
||||||
|
const [lastName, setLastName] = useState(initialName.lastName);
|
||||||
const [email, setEmail] = useState(user?.email ?? "");
|
const [email, setEmail] = useState(user?.email ?? "");
|
||||||
const [currentPassword, setCurrentPassword] = useState("");
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const emailChanged = email !== user?.email;
|
const emailChanged = email !== user?.email;
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () => updateProfile({ name, email, currentPassword: currentPassword || undefined }),
|
mutationFn: () =>
|
||||||
|
updateProfile({
|
||||||
|
name: [firstName, lastName].filter(Boolean).join(" "),
|
||||||
|
email,
|
||||||
|
currentPassword: currentPassword || undefined,
|
||||||
|
}),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
setCurrentPassword("");
|
setCurrentPassword("");
|
||||||
await refreshUser();
|
await refreshUser();
|
||||||
@@ -131,19 +145,32 @@ function ProfileInfoSection() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Profil</CardTitle>
|
<CardTitle>Profil</CardTitle>
|
||||||
<CardDescription>Name und E-Mail-Adresse</CardDescription>
|
<CardDescription className="flex items-center gap-2">
|
||||||
|
Name, E-Mail-Adresse und Rolle
|
||||||
|
{user ? <Badge variant="secondary">{user.roleName}</Badge> : null}
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex gap-3">
|
||||||
<Label htmlFor="profile-name">Name</Label>
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<Label htmlFor="profile-first-name">Vorname</Label>
|
||||||
<Input
|
<Input
|
||||||
id="profile-name"
|
id="profile-first-name"
|
||||||
required
|
required
|
||||||
value={name}
|
value={firstName}
|
||||||
onChange={(event) => setName(event.target.value)}
|
onChange={(event) => setFirstName(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<Label htmlFor="profile-last-name">Nachname</Label>
|
||||||
|
<Input
|
||||||
|
id="profile-last-name"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(event) => setLastName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="profile-email">E-Mail</Label>
|
<Label htmlFor="profile-email">E-Mail</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -270,6 +297,7 @@ export default function ProfilePage() {
|
|||||||
<ProfileInfoSection />
|
<ProfileInfoSection />
|
||||||
<PasswordSection />
|
<PasswordSection />
|
||||||
<MfaSection mfaEnabled={user.mfaEnabled} onChanged={() => void refreshUser()} />
|
<MfaSection mfaEnabled={user.mfaEnabled} onChanged={() => void refreshUser()} />
|
||||||
|
<DangerZoneSection />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,12 +34,20 @@ export default function QueenDetailPage() {
|
|||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<QrCodeDialog entityType="queens" id={queen.id} />
|
<QrCodeDialog entityType="queens" id={queen.id} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-3">
|
||||||
|
<div className="flex flex-col gap-4 lg:col-span-1">
|
||||||
<QueenEditCard key={queen.id} queen={queen} />
|
<QueenEditCard key={queen.id} queen={queen} />
|
||||||
<AssignColonyDialog queenId={queen.id} />
|
<AssignColonyDialog queenId={queen.id} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4 lg:col-span-2">
|
||||||
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||||
<QueenEvaluationsCard queenId={queen.id} />
|
<QueenEvaluationsCard queenId={queen.id} />
|
||||||
<QueenLineageCard queenId={queen.id} />
|
<QueenLineageCard queenId={queen.id} />
|
||||||
<QueenHistoryCard queenId={queen.id} />
|
|
||||||
<DocumentUploadCard entityType="QUEEN" entityId={queen.id} />
|
<DocumentUploadCard entityType="QUEEN" entityId={queen.id} />
|
||||||
|
</div>
|
||||||
|
<QueenHistoryCard queenId={queen.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@ export function QueenEditCard({ queen }: { queen: Queen }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="max-w-lg">
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function QueenEvaluationsCard({ queenId }: { queenId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="max-w-lg">
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle>Bewertung</CardTitle>
|
<CardTitle>Bewertung</CardTitle>
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function QueenHistoryCard({ queenId }: { queenId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="max-w-lg">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Volk-Historie</CardTitle>
|
<CardTitle>Volk-Historie</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function QueenLineageCard({ queenId }: { queenId: string }) {
|
|||||||
const hasLineage = ancestors.length > 0 || (lineage?.descendants.length ?? 0) > 0;
|
const hasLineage = ancestors.length > 0 || (lineage?.descendants.length ?? 0) > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="max-w-lg">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<GitBranch className="size-4" />
|
<GitBranch className="size-4" />
|
||||||
|
|||||||
@@ -14,18 +14,27 @@ import { PageHeader } from "@/components/page-header";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
const TRIGGER_LABELS: Record<AutomationTriggerType, string> = {
|
const TRIGGER_LABELS: Record<AutomationTriggerType, string> = {
|
||||||
QUEENLESS_NO_EGGS: "Keine Königin gesehen & keine Stifte",
|
QUEENLESS_NO_EGGS: "Keine Königin gesehen und keine Stifte",
|
||||||
VARROA_THRESHOLD: "Varroa-Schwellenwert überschritten",
|
VARROA_THRESHOLD: "Varroa-Schwellenwert überschritten",
|
||||||
FEED_STATUS_CRITICAL: "Futterstatus kritisch",
|
FEED_STATUS_CRITICAL: "Futterstatus kritisch",
|
||||||
|
SWARM_MOOD_HIGH: "Hohe Schwarmstimmung",
|
||||||
|
COLONY_HEALTH_CRITICAL: "Gesundheitsstatus kritisch",
|
||||||
|
STOCK_ITEM_LOW: "Lagerartikel unter Mindestbestand",
|
||||||
};
|
};
|
||||||
|
|
||||||
const TRIGGER_DESCRIPTIONS: Record<AutomationTriggerType, string> = {
|
const TRIGGER_DESCRIPTIONS: Record<AutomationTriggerType, string> = {
|
||||||
QUEENLESS_NO_EGGS:
|
QUEENLESS_NO_EGGS:
|
||||||
"Bei einer Durchsicht: Königin nicht gesehen und keine Stifte vorhanden → Kontrollaufgabe.",
|
"Bei einer Durchsicht: Königin nicht gesehen und keine Stifte vorhanden, dann Kontrollaufgabe.",
|
||||||
VARROA_THRESHOLD:
|
VARROA_THRESHOLD:
|
||||||
"Milbenzahl (bzw. Milbenfall pro Tag) über dem Schwellenwert → Behandlung prüfen.",
|
"Milbenzahl (bzw. Milbenfall pro Tag) über dem Schwellenwert, dann Behandlung prüfen.",
|
||||||
FEED_STATUS_CRITICAL:
|
FEED_STATUS_CRITICAL:
|
||||||
"Futterstatus bei einer Durchsicht auf „kritisch“ gesetzt → Fütterungsaufgabe.",
|
"Futterstatus bei einer Durchsicht auf kritisch gesetzt, dann Fütterungsaufgabe.",
|
||||||
|
SWARM_MOOD_HIGH:
|
||||||
|
"Bei einer Schwarmkontrolle Schwarmstimmung hoch erfasst, dann Kontrollaufgabe.",
|
||||||
|
COLONY_HEALTH_CRITICAL:
|
||||||
|
"Gesundheitsstatus eines Volkes wechselt auf kritisch, dann Kontrollaufgabe.",
|
||||||
|
STOCK_ITEM_LOW:
|
||||||
|
"Bestand eines Lagerartikels fällt nach einer Buchung unter den Mindestbestand, dann Nachbestellaufgabe.",
|
||||||
};
|
};
|
||||||
|
|
||||||
function ruleThreshold(rule: AutomationRule): string {
|
function ruleThreshold(rule: AutomationRule): string {
|
||||||
@@ -59,7 +68,7 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="max-w-lg">
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle>{TRIGGER_LABELS[rule.triggerType]}</CardTitle>
|
<CardTitle>{TRIGGER_LABELS[rule.triggerType]}</CardTitle>
|
||||||
<Badge variant={rule.active ? "default" : "secondary"}>
|
<Badge variant={rule.active ? "default" : "secondary"}>
|
||||||
@@ -128,9 +137,11 @@ export default function AutomationRulesPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||||
{rules?.map((rule) => (
|
{rules?.map((rule) => (
|
||||||
<RuleCard key={rule.id} rule={rule} />
|
<RuleCard key={rule.id} rule={rule} />
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { createLabelTemplate } from "@/lib/label-templates/api";
|
||||||
|
import { LABEL_PLACEHOLDERS } from "@/lib/label-templates/types";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
export function CreateLabelTemplateDialog() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [widthMm, setWidthMm] = useState("90");
|
||||||
|
const [heightMm, setHeightMm] = useState("35");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setName("");
|
||||||
|
setContent("");
|
||||||
|
setWidthMm("90");
|
||||||
|
setHeightMm("35");
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: createLabelTemplate,
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["label-templates"] });
|
||||||
|
setOpen(false);
|
||||||
|
reset();
|
||||||
|
},
|
||||||
|
onError: () => setError("Vorlage konnte nicht angelegt werden."),
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
mutation.mutate({
|
||||||
|
name,
|
||||||
|
content,
|
||||||
|
widthMm: widthMm ? Number(widthMm) : undefined,
|
||||||
|
heightMm: heightMm ? Number(heightMm) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
setOpen(next);
|
||||||
|
if (!next) reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button onClick={() => setOpen(true)}>+ Neue Etikettenvorlage</Button>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Neue Etikettenvorlage</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="label-template-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="label-template-name"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<Label htmlFor="label-template-width">Breite (mm)</Label>
|
||||||
|
<Input
|
||||||
|
id="label-template-width"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={widthMm}
|
||||||
|
onChange={(event) => setWidthMm(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<Label htmlFor="label-template-height">Höhe (mm)</Label>
|
||||||
|
<Input
|
||||||
|
id="label-template-height"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={heightMm}
|
||||||
|
onChange={(event) => setHeightMm(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="label-template-content">Etikettentext</Label>
|
||||||
|
<Textarea
|
||||||
|
id="label-template-content"
|
||||||
|
required
|
||||||
|
rows={6}
|
||||||
|
value={content}
|
||||||
|
onChange={(event) => setContent(event.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Platzhalter:{" "}
|
||||||
|
{LABEL_PLACEHOLDERS.map((placeholder) => `{{${placeholder.key}}}`).join(", ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
import { Pencil } from "lucide-react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { updateLabelTemplate } from "@/lib/label-templates/api";
|
||||||
|
import { LABEL_PLACEHOLDERS, LabelTemplate } from "@/lib/label-templates/types";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
export function EditLabelTemplateDialog({ template }: { template: LabelTemplate }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [name, setName] = useState(template.name);
|
||||||
|
const [content, setContent] = useState(template.content);
|
||||||
|
const [widthMm, setWidthMm] = useState(template.widthMm.toString());
|
||||||
|
const [heightMm, setHeightMm] = useState(template.heightMm.toString());
|
||||||
|
const [isDefault, setIsDefault] = useState(template.isDefault);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
updateLabelTemplate(template.id, {
|
||||||
|
name,
|
||||||
|
content,
|
||||||
|
widthMm: widthMm ? Number(widthMm) : undefined,
|
||||||
|
heightMm: heightMm ? Number(heightMm) : undefined,
|
||||||
|
isDefault,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["label-templates"] });
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
mutation.mutate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Etikettenvorlage bearbeiten</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="label-template-edit-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="label-template-edit-name"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<Label htmlFor="label-template-edit-width">Breite (mm)</Label>
|
||||||
|
<Input
|
||||||
|
id="label-template-edit-width"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={widthMm}
|
||||||
|
onChange={(event) => setWidthMm(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<Label htmlFor="label-template-edit-height">Höhe (mm)</Label>
|
||||||
|
<Input
|
||||||
|
id="label-template-edit-height"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={heightMm}
|
||||||
|
onChange={(event) => setHeightMm(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="label-template-edit-content">Etikettentext</Label>
|
||||||
|
<Textarea
|
||||||
|
id="label-template-edit-content"
|
||||||
|
required
|
||||||
|
rows={6}
|
||||||
|
value={content}
|
||||||
|
onChange={(event) => setContent(event.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Platzhalter:{" "}
|
||||||
|
{LABEL_PLACEHOLDERS.map((placeholder) => `{{${placeholder.key}}}`).join(", ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm" htmlFor="label-template-edit-default">
|
||||||
|
<input
|
||||||
|
id="label-template-edit-default"
|
||||||
|
type="checkbox"
|
||||||
|
checked={isDefault}
|
||||||
|
onChange={(event) => setIsDefault(event.target.checked)}
|
||||||
|
/>
|
||||||
|
Als Standardvorlage verwenden
|
||||||
|
</label>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? "Speichert…" : "Speichern"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Tag } from "lucide-react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { deactivateLabelTemplate, fetchLabelTemplates } from "@/lib/label-templates/api";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { EmptyState } from "@/components/empty-state";
|
||||||
|
import { EntityCard } from "@/components/entity-card";
|
||||||
|
import { CreateLabelTemplateDialog } from "./create-label-template-dialog";
|
||||||
|
import { EditLabelTemplateDialog } from "./edit-label-template-dialog";
|
||||||
|
|
||||||
|
export default function LabelTemplatesPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { data: templates, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ["label-templates"],
|
||||||
|
queryFn: fetchLabelTemplates,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deactivateMutation = useMutation({
|
||||||
|
mutationFn: deactivateLabelTemplate,
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["label-templates"] });
|
||||||
|
toast.success("Vorlage deaktiviert");
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Vorlage konnte nicht deaktiviert werden"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeTemplates = (templates ?? []).filter((template) => template.active);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
|
<PageHeader
|
||||||
|
icon={Tag}
|
||||||
|
title="Etikettenvorlagen"
|
||||||
|
description="Vorlagen mit Platzhaltern für den Etikettendruck verwalten"
|
||||||
|
gradient="var(--gradient-sky)"
|
||||||
|
actions={<CreateLabelTemplateDialog />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||||
|
{isError ? (
|
||||||
|
<p role="alert" className="text-sm text-destructive">
|
||||||
|
Etikettenvorlagen konnten nicht geladen werden.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{templates && activeTemplates.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Tag}
|
||||||
|
title="Noch keine Etikettenvorlagen"
|
||||||
|
description="Lege eine Vorlage an, um Etiketten für Abfüllungen zu drucken."
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeTemplates.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{activeTemplates.map((template) => (
|
||||||
|
<EntityCard
|
||||||
|
key={template.id}
|
||||||
|
icon={Tag}
|
||||||
|
gradient="var(--gradient-sky)"
|
||||||
|
title={template.name}
|
||||||
|
badge={template.isDefault ? <Badge>Standard</Badge> : undefined}
|
||||||
|
fields={[
|
||||||
|
{ label: "Größe", value: `${template.widthMm} × ${template.heightMm} mm` },
|
||||||
|
]}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<EditLabelTemplateDialog template={template} />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={deactivateMutation.isPending}
|
||||||
|
onClick={() => deactivateMutation.mutate(template.id)}
|
||||||
|
>
|
||||||
|
Deaktivieren
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Crown,
|
Crown,
|
||||||
Droplet,
|
Droplet,
|
||||||
FileClock,
|
FileClock,
|
||||||
|
FlaskConical,
|
||||||
Hexagon,
|
Hexagon,
|
||||||
History,
|
History,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -17,9 +18,11 @@ import {
|
|||||||
MapPin,
|
MapPin,
|
||||||
Package,
|
Package,
|
||||||
Repeat,
|
Repeat,
|
||||||
|
Scissors,
|
||||||
Search,
|
Search,
|
||||||
Shield,
|
Shield,
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
|
Tag,
|
||||||
Upload,
|
Upload,
|
||||||
Users,
|
Users,
|
||||||
Warehouse,
|
Warehouse,
|
||||||
@@ -68,6 +71,8 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
{
|
{
|
||||||
label: "Ernte & Lager",
|
label: "Ernte & Lager",
|
||||||
items: [
|
items: [
|
||||||
|
{ label: "Ernten", href: "/harvests/new", icon: Scissors },
|
||||||
|
{ label: "Schleuderungen", href: "/extractions/new", icon: FlaskConical },
|
||||||
{ label: "Honigchargen", href: "/honey-batches", icon: Droplet },
|
{ label: "Honigchargen", href: "/honey-batches", icon: Droplet },
|
||||||
{ label: "Abfüllungen", href: "/bottling/new", icon: Package },
|
{ label: "Abfüllungen", href: "/bottling/new", icon: Package },
|
||||||
{ label: "Produkte", href: "/products", icon: ShoppingBag },
|
{ label: "Produkte", href: "/products", icon: ShoppingBag },
|
||||||
@@ -100,6 +105,12 @@ const ADMIN_GROUP: NavGroup = {
|
|||||||
icon: Repeat,
|
icon: Repeat,
|
||||||
permission: "task-templates:read",
|
permission: "task-templates:read",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Etikettenvorlagen",
|
||||||
|
href: "/settings/label-templates",
|
||||||
|
icon: Tag,
|
||||||
|
permission: "label-templates:read",
|
||||||
|
},
|
||||||
{ label: "Import", href: "/settings/import", icon: Upload, permission: "import:execute" },
|
{ label: "Import", href: "/settings/import", icon: Upload, permission: "import:execute" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,27 +4,44 @@ import userEvent from "@testing-library/user-event";
|
|||||||
import LoginPage from "./page";
|
import LoginPage from "./page";
|
||||||
|
|
||||||
const pushMock = vi.fn();
|
const pushMock = vi.fn();
|
||||||
|
const replaceMock = vi.fn();
|
||||||
|
const searchParamsMock = vi.fn(() => new URLSearchParams());
|
||||||
vi.mock("next/navigation", () => ({
|
vi.mock("next/navigation", () => ({
|
||||||
useRouter: () => ({ push: pushMock }),
|
useRouter: () => ({ push: pushMock, replace: replaceMock }),
|
||||||
|
useSearchParams: () => searchParamsMock(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const loginMock = vi.fn();
|
const loginMock = vi.fn();
|
||||||
const verifyMfaMock = vi.fn();
|
const verifyMfaMock = vi.fn();
|
||||||
|
const completeSsoMock = vi.fn();
|
||||||
vi.mock("@/lib/auth/auth-context", () => ({
|
vi.mock("@/lib/auth/auth-context", () => ({
|
||||||
useAuth: () => ({
|
useAuth: () => ({
|
||||||
login: loginMock,
|
login: loginMock,
|
||||||
verifyMfa: verifyMfaMock,
|
verifyMfa: verifyMfaMock,
|
||||||
|
completeSso: completeSsoMock,
|
||||||
user: null,
|
user: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
logout: vi.fn(),
|
logout: vi.fn(),
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const ssoStatusRequestMock = vi.fn().mockResolvedValue(false);
|
||||||
|
const ssoLoginUrlMock = vi.fn().mockResolvedValue("http://localhost:3001/api/v1/auth/sso/login");
|
||||||
|
vi.mock("@/lib/auth/api", () => ({
|
||||||
|
ssoStatusRequest: () => ssoStatusRequestMock(),
|
||||||
|
ssoLoginUrl: () => ssoLoginUrlMock(),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("LoginPage", () => {
|
describe("LoginPage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
pushMock.mockReset();
|
pushMock.mockReset();
|
||||||
|
replaceMock.mockReset();
|
||||||
loginMock.mockReset();
|
loginMock.mockReset();
|
||||||
verifyMfaMock.mockReset();
|
verifyMfaMock.mockReset();
|
||||||
|
completeSsoMock.mockReset();
|
||||||
|
searchParamsMock.mockReturnValue(new URLSearchParams());
|
||||||
|
ssoStatusRequestMock.mockReset().mockResolvedValue(false);
|
||||||
|
ssoLoginUrlMock.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("submits credentials and redirects on success", async () => {
|
it("submits credentials and redirects on success", async () => {
|
||||||
@@ -83,4 +100,22 @@ describe("LoginPage", () => {
|
|||||||
|
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent(/ungültig/i);
|
expect(await screen.findByRole("alert")).toHaveTextContent(/ungültig/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows the Microsoft SSO button when SSO is enabled", async () => {
|
||||||
|
ssoStatusRequestMock.mockResolvedValue(true);
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
const link = await screen.findByRole("link", { name: /mit microsoft anmelden/i });
|
||||||
|
expect(link).toHaveAttribute("href", "http://localhost:3001/api/v1/auth/sso/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes SSO login and redirects to the dashboard when returning with ssoPending", async () => {
|
||||||
|
searchParamsMock.mockReturnValue(new URLSearchParams("ssoPending=1"));
|
||||||
|
completeSsoMock.mockResolvedValueOnce(undefined);
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/microsoft-anmeldung wird abgeschlossen/i)).toBeInTheDocument();
|
||||||
|
expect(completeSsoMock).toHaveBeenCalled();
|
||||||
|
expect(replaceMock).toHaveBeenCalledWith("/dashboard");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, Suspense, useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Hexagon } from "lucide-react";
|
import { Hexagon } from "lucide-react";
|
||||||
import { useAuth } from "@/lib/auth/auth-context";
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
|
import { ssoLoginUrl, ssoStatusRequest } from "@/lib/auth/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -16,15 +17,70 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
const SSO_ERROR_MESSAGES: Record<string, string> = {
|
||||||
|
session_expired: "Sitzung abgelaufen, bitte erneut versuchen.",
|
||||||
|
exchange_failed: "Anmeldung bei Microsoft fehlgeschlagen, bitte erneut versuchen.",
|
||||||
|
no_email: "Das Microsoft-Konto enthält keine E-Mail-Adresse.",
|
||||||
|
no_account:
|
||||||
|
"Kein ApiCore-Konto mit dieser E-Mail-Adresse gefunden. Bitte an eine Administratorin oder einen Administrator wenden.",
|
||||||
|
};
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<LoginPageContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginPageContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { login, verifyMfa } = useAuth();
|
const searchParams = useSearchParams();
|
||||||
|
const { login, verifyMfa, completeSso } = useAuth();
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [code, setCode] = useState("");
|
const [code, setCode] = useState("");
|
||||||
const [mfaToken, setMfaToken] = useState<string | null>(null);
|
const [mfaToken, setMfaToken] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const ssoPending = searchParams.get("ssoPending") === "1";
|
||||||
|
const [error, setError] = useState<string | null>(() => {
|
||||||
|
const ssoErrorParam = searchParams.get("ssoError");
|
||||||
|
return ssoErrorParam
|
||||||
|
? (SSO_ERROR_MESSAGES[ssoErrorParam] ?? "Anmeldung fehlgeschlagen, bitte erneut versuchen.")
|
||||||
|
: null;
|
||||||
|
});
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [ssoEnabled, setSsoEnabled] = useState(false);
|
||||||
|
const [ssoLoginHref, setSsoLoginHref] = useState<string | null>(null);
|
||||||
|
const isCompletingSso = ssoPending;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ssoStatusRequest().then(setSsoEnabled);
|
||||||
|
ssoLoginUrl().then(setSsoLoginHref);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cleans the one-time ssoPending/ssoError query params off the URL. The
|
||||||
|
// error message itself was already captured into state above (from the
|
||||||
|
// initial render), so this replace never needs to touch React state. The
|
||||||
|
// actual sso-complete code never appears here - it travels only in an
|
||||||
|
// httpOnly cookie the callback set, which ssoCompleteRequest() picks up.
|
||||||
|
useEffect(() => {
|
||||||
|
if (ssoPending) {
|
||||||
|
completeSso()
|
||||||
|
.then(() => router.replace("/dashboard"))
|
||||||
|
.catch(() => {
|
||||||
|
setError("SSO-Anmeldung fehlgeschlagen, bitte erneut versuchen.");
|
||||||
|
router.replace("/login");
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (searchParams.get("ssoError")) {
|
||||||
|
router.replace("/login");
|
||||||
|
}
|
||||||
|
// Runs once per navigation to /login?ssoPending=1|?ssoError=...;
|
||||||
|
// completeSso and router are stable across renders so this only needs
|
||||||
|
// ssoPending.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [ssoPending]);
|
||||||
|
|
||||||
async function handleCredentialsSubmit(event: FormEvent<HTMLFormElement>) {
|
async function handleCredentialsSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -82,7 +138,9 @@ export default function LoginPage() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{mfaToken ? (
|
{isCompletingSso ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Microsoft-Anmeldung wird abgeschlossen…</p>
|
||||||
|
) : mfaToken ? (
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleMfaSubmit}>
|
<form className="flex flex-col gap-4" onSubmit={handleMfaSubmit}>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="code">Code</Label>
|
<Label htmlFor="code">Code</Label>
|
||||||
@@ -150,6 +208,20 @@ export default function LoginPage() {
|
|||||||
<Button type="submit" disabled={isSubmitting}>
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
{isSubmitting ? "Anmelden…" : "Anmelden"}
|
{isSubmitting ? "Anmelden…" : "Anmelden"}
|
||||||
</Button>
|
</Button>
|
||||||
|
{ssoEnabled && ssoLoginHref ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="h-px flex-1 bg-border" />
|
||||||
|
oder
|
||||||
|
<span className="h-px flex-1 bg-border" />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
render={<a href={ssoLoginHref}>Mit Microsoft anmelden</a>}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -72,6 +72,39 @@ export function refreshRequest(): Promise<AccessTokenResponse | null> {
|
|||||||
return inFlightRefresh;
|
return inFlightRefresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function ssoStatusRequest(): Promise<boolean> {
|
||||||
|
const apiUrl = await getApiUrl();
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiUrl}/auth/sso/status`);
|
||||||
|
if (!response.ok) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as { enabled: boolean };
|
||||||
|
return data.enabled;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ssoLoginUrl(): Promise<string> {
|
||||||
|
const apiUrl = await getApiUrl();
|
||||||
|
return `${apiUrl}/auth/sso/login`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one-time completion code lives only in an httpOnly cookie the callback
|
||||||
|
// set (never in the URL or a request body) - the browser sends it automatically.
|
||||||
|
export async function ssoCompleteRequest(): Promise<AccessTokenResponse> {
|
||||||
|
const apiUrl = await getApiUrl();
|
||||||
|
const response = await fetch(`${apiUrl}/auth/sso/complete`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('SSO-Anmeldung fehlgeschlagen. Bitte erneut versuchen.');
|
||||||
|
}
|
||||||
|
return response.json() as Promise<AccessTokenResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
export async function logoutRequest(): Promise<void> {
|
export async function logoutRequest(): Promise<void> {
|
||||||
const apiUrl = await getApiUrl();
|
const apiUrl = await getApiUrl();
|
||||||
await fetch(`${apiUrl}/auth/logout`, { method: 'POST', credentials: 'include' });
|
await fetch(`${apiUrl}/auth/logout`, { method: 'POST', credentials: 'include' });
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
loginRequest,
|
loginRequest,
|
||||||
logoutRequest,
|
logoutRequest,
|
||||||
refreshRequest,
|
refreshRequest,
|
||||||
|
ssoCompleteRequest,
|
||||||
verifyMfaRequest,
|
verifyMfaRequest,
|
||||||
} from './api';
|
} from './api';
|
||||||
import { getAccessToken, setAccessToken as storeAccessToken } from './token-store';
|
import { getAccessToken, setAccessToken as storeAccessToken } from './token-store';
|
||||||
@@ -29,6 +30,7 @@ interface AuthContextValue {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
login: (email: string, password: string) => Promise<LoginOutcome>;
|
login: (email: string, password: string) => Promise<LoginOutcome>;
|
||||||
verifyMfa: (mfaToken: string, code: string) => Promise<void>;
|
verifyMfa: (mfaToken: string, code: string) => Promise<void>;
|
||||||
|
completeSso: () => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
hasPermission: (permission: string) => boolean;
|
hasPermission: (permission: string) => boolean;
|
||||||
refreshUser: () => Promise<void>;
|
refreshUser: () => Promise<void>;
|
||||||
@@ -76,6 +78,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setUser(await applyAccessToken(result.accessToken));
|
setUser(await applyAccessToken(result.accessToken));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const completeSso = useCallback(async () => {
|
||||||
|
const result = await ssoCompleteRequest();
|
||||||
|
setUser(await applyAccessToken(result.accessToken));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
await logoutRequest();
|
await logoutRequest();
|
||||||
storeAccessToken(null);
|
storeAccessToken(null);
|
||||||
@@ -100,7 +107,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider
|
<AuthContext.Provider
|
||||||
value={{ user, isLoading, login, verifyMfa, logout, hasPermission, refreshUser }}
|
value={{
|
||||||
|
user,
|
||||||
|
isLoading,
|
||||||
|
login,
|
||||||
|
verifyMfa,
|
||||||
|
completeSso,
|
||||||
|
logout,
|
||||||
|
hasPermission,
|
||||||
|
refreshUser,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
export type AutomationTriggerType =
|
export type AutomationTriggerType =
|
||||||
| "QUEENLESS_NO_EGGS"
|
| "QUEENLESS_NO_EGGS"
|
||||||
| "VARROA_THRESHOLD"
|
| "VARROA_THRESHOLD"
|
||||||
| "FEED_STATUS_CRITICAL";
|
| "FEED_STATUS_CRITICAL"
|
||||||
|
| "SWARM_MOOD_HIGH"
|
||||||
|
| "COLONY_HEALTH_CRITICAL"
|
||||||
|
| "STOCK_ITEM_LOW";
|
||||||
|
|
||||||
export interface AutomationRule {
|
export interface AutomationRule {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { apiFetch, apiFetchBlob } from "@/lib/auth/api-client";
|
||||||
|
import { downloadBlob } from "@/lib/export/api";
|
||||||
|
import { CreateLabelTemplateInput, LabelTemplate, UpdateLabelTemplateInput } from "./types";
|
||||||
|
|
||||||
|
export function fetchLabelTemplates(): Promise<LabelTemplate[]> {
|
||||||
|
return apiFetch<LabelTemplate[]>("/label-templates");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLabelTemplate(input: CreateLabelTemplateInput): Promise<LabelTemplate> {
|
||||||
|
return apiFetch<LabelTemplate>("/label-templates", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateLabelTemplate(
|
||||||
|
id: string,
|
||||||
|
input: UpdateLabelTemplateInput,
|
||||||
|
): Promise<LabelTemplate> {
|
||||||
|
return apiFetch<LabelTemplate>(`/label-templates/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deactivateLabelTemplate(id: string): Promise<LabelTemplate> {
|
||||||
|
return apiFetch<LabelTemplate>(`/label-templates/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function printLabels(templateId: string, bottlingBatchId: string): Promise<void> {
|
||||||
|
const { blob, filename } = await apiFetchBlob(
|
||||||
|
`/label-templates/${templateId}/print/${bottlingBatchId}`,
|
||||||
|
"etiketten.pdf",
|
||||||
|
);
|
||||||
|
downloadBlob(blob, filename);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export interface LabelTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
widthMm: number;
|
||||||
|
heightMm: number;
|
||||||
|
isDefault: boolean;
|
||||||
|
active: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateLabelTemplateInput {
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
widthMm?: number;
|
||||||
|
heightMm?: number;
|
||||||
|
isDefault?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLabelTemplateInput {
|
||||||
|
name?: string;
|
||||||
|
content?: string;
|
||||||
|
widthMm?: number;
|
||||||
|
heightMm?: number;
|
||||||
|
isDefault?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LABEL_PLACEHOLDERS: { key: string; description: string }[] = [
|
||||||
|
{ key: "batchNumber", description: "Chargennummer der Honigcharge" },
|
||||||
|
{ key: "honeyType", description: "Honigsorte" },
|
||||||
|
{ key: "lotNumber", description: "Los-/Chargennummer der Abfüllung" },
|
||||||
|
{ key: "bottlingDate", description: "Abfülldatum" },
|
||||||
|
{ key: "bestBeforeDate", description: "Mindesthaltbarkeitsdatum (MHD)" },
|
||||||
|
{ key: "jarSize", description: "Glasgröße" },
|
||||||
|
{ key: "productName", description: "Produktname" },
|
||||||
|
{
|
||||||
|
key: "qrCode",
|
||||||
|
description: "QR-Code zur öffentlichen Herkunftsseite der Charge (Honigsorte, Daten, Rückverfolgbarkeit)",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -15,6 +15,13 @@ export function changePassword(currentPassword: string, newPassword: string): Pr
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deactivateAccount(currentPassword: string): Promise<void> {
|
||||||
|
return apiFetch<void>("/profile", {
|
||||||
|
method: "DELETE",
|
||||||
|
body: JSON.stringify({ currentPassword }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function uploadAvatar(file: File): Promise<void> {
|
export function uploadAvatar(file: File): Promise<void> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
|
|||||||
@@ -9,3 +9,9 @@ data:
|
|||||||
FRONTEND_ORIGIN: "https://apicore.example.com"
|
FRONTEND_ORIGIN: "https://apicore.example.com"
|
||||||
DOCUMENTS_STORAGE_PATH: "/data/documents"
|
DOCUMENTS_STORAGE_PATH: "/data/documents"
|
||||||
COOKIE_SECURE: "true"
|
COOKIE_SECURE: "true"
|
||||||
|
# Entra ID SSO. Issuer ist die v2.0-Metadaten-URL des Tenants,
|
||||||
|
# Redirect-URI muss exakt der im App-Registrierung hinterlegten entsprechen.
|
||||||
|
# Ohne diese vier Variablen (siehe secret.example.yaml fuer Client-ID/-Secret)
|
||||||
|
# bleibt SSO deaktiviert, /auth/sso/status meldet dann enabled: false.
|
||||||
|
OIDC_ISSUER_URL: "https://login.microsoftonline.com/<tenant-id>/v2.0"
|
||||||
|
OIDC_REDIRECT_URI: "https://api.apicore.example.com/api/v1/auth/sso/callback"
|
||||||
@@ -12,3 +12,7 @@ stringData:
|
|||||||
DATABASE_URL: "postgresql://apicore:change-me-postgres-password@apicore-postgres:5432/apicore?schema=public"
|
DATABASE_URL: "postgresql://apicore:change-me-postgres-password@apicore-postgres:5432/apicore?schema=public"
|
||||||
JWT_ACCESS_SECRET: "change-me-access-secret"
|
JWT_ACCESS_SECRET: "change-me-access-secret"
|
||||||
JWT_REFRESH_SECRET: "change-me-refresh-secret"
|
JWT_REFRESH_SECRET: "change-me-refresh-secret"
|
||||||
|
# Entra ID App-Registrierung (SSO). Siehe configmap.example.yaml
|
||||||
|
# fuer OIDC_ISSUER_URL/OIDC_REDIRECT_URI.
|
||||||
|
OIDC_CLIENT_ID: "change-me-entra-client-id"
|
||||||
|
OIDC_CLIENT_SECRET: "change-me-entra-client-secret"
|
||||||
Reference in new issue
Block a user