feat: Profilseite, Etikettendruck mit QR-Code, mehr Automatisierungen und Entra-ID-SSO
Backend Image / build-and-push (push) Successful in 2m28s
Frontend Image / build-and-push (push) Successful in 2m10s

- 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:
Nick Szaunig committed 2026-08-31 18:56:08 +02:00
1 parent 0a52b29c57
commit 8432313fa9
83 files changed
+2508 -113

No files matched your search

+1
View File
@@ -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.
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.
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
+9
View File
@@ -19,3 +19,12 @@ COOKIE_SECURE=false
# Bei horizontaler Skalierung (>1 Replica) muss dies auf ein geteiltes
# Volume (z.B. ReadWriteMany-PVC) zeigen, siehe ARCHITECTURE.md Annahme 7.
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"
+32
View File
@@ -24,6 +24,7 @@
"class-validator": "^0.15.1",
"cookie-parser": "^1.4.7",
"helmet": "^8.3.0",
"openid-client": "^6.8.4",
"otplib": "^13.5.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
@@ -8681,6 +8682,15 @@
"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": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
@@ -9648,6 +9658,15 @@
"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": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -9712,6 +9731,19 @@
"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": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+2 -1
View File
@@ -38,6 +38,7 @@
"class-validator": "^0.15.1",
"cookie-parser": "^1.4.7",
"helmet": "^8.3.0",
"openid-client": "^6.8.4",
"otplib": "^13.5.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
@@ -90,7 +91,7 @@
"^.+\\.(t|j)s$": "ts-jest"
},
"transformIgnorePatterns": [
"node_modules/(?!(otplib|@otplib|@scure|@noble)/)"
"node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose)/)"
],
"collectCoverageFrom": [
"**/*.(t|j)s"
@@ -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");
+19
View File
@@ -853,6 +853,22 @@ model Product {
@@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 {
id String @id @default(cuid())
honeyBatchId String
@@ -981,6 +997,9 @@ enum AutomationTriggerType {
QUEENLESS_NO_EGGS
VARROA_THRESHOLD
FEED_STATUS_CRITICAL
SWARM_MOOD_HIGH
COLONY_HEALTH_CRITICAL
STOCK_ITEM_LOW
}
model AutomationRule {
+2
View File
@@ -33,6 +33,7 @@ import { StatisticsModule } from './statistics/statistics.module';
import { WebhooksModule } from './webhooks/webhooks.module';
import { DocumentsModule } from './documents/documents.module';
import { AutomationModule } from './automation/automation.module';
import { LabelTemplatesModule } from './label-templates/label-templates.module';
import { ImportModule } from './import/import.module';
import { PublicModule } from './public/public.module';
import { TaskTemplatesModule } from './task-templates/task-templates.module';
@@ -79,6 +80,7 @@ import { PermissionsModule } from './auth/permissions/permissions.module';
WebhooksModule,
DocumentsModule,
AutomationModule,
LabelTemplatesModule,
ImportModule,
PublicModule,
TaskTemplatesModule,
+11 -38
View File
@@ -7,7 +7,6 @@ import {
Post,
Req,
Res,
UnauthorizedException,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
@@ -19,49 +18,23 @@ import { LoginDto } from './dto/login.dto';
import { AccessTokenDto } from './dto/access-token.dto';
import { MfaVerifyDto } from './dto/mfa-verify.dto';
import { AuthService, CurrentUserProfile } from './auth.service';
import { parseDurationToMs } from './util/duration';
import { SENSITIVE_ACTION_THROTTLE } from './util/throttle';
import {
clearRefreshTokenCookie,
extractRefreshToken,
setRefreshTokenCookie,
} from './util/refresh-token-cookie';
interface MfaRequiredResponse {
mfaRequired: true;
mfaToken: string;
}
const REFRESH_TOKEN_COOKIE = 'refreshToken';
const REFRESH_TOKEN_COOKIE_PATH = '/api/v1/auth';
@ApiTags('auth')
@Controller('auth')
export class AuthController {
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()
@Throttle(SENSITIVE_ACTION_THROTTLE)
@Post('login')
@@ -74,7 +47,7 @@ export class AuthController {
if (result.mfaRequired) {
return { mfaRequired: true, mfaToken: result.mfaToken };
}
this.setRefreshTokenCookie(res, result.refreshToken);
setRefreshTokenCookie(res, result.refreshToken);
return { accessToken: result.accessToken };
}
@@ -88,7 +61,7 @@ export class AuthController {
): Promise<AccessTokenDto> {
const { accessToken, refreshToken } =
await this.authService.verifyMfaAndLogin(dto.mfaToken, dto.code);
this.setRefreshTokenCookie(res, refreshToken);
setRefreshTokenCookie(res, refreshToken);
return { accessToken };
}
@@ -101,9 +74,9 @@ export class AuthController {
@Res({ passthrough: true }) res: Response,
): Promise<AccessTokenDto> {
const { accessToken, refreshToken } = await this.authService.refresh(
this.extractRefreshToken(req),
extractRefreshToken(req),
);
this.setRefreshTokenCookie(res, refreshToken);
setRefreshTokenCookie(res, refreshToken);
return { accessToken };
}
@@ -114,8 +87,8 @@ export class AuthController {
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<void> {
await this.authService.logout(this.extractRefreshToken(req));
this.clearRefreshTokenCookie(res);
await this.authService.logout(extractRefreshToken(req));
clearRefreshTokenCookie(res);
}
@ApiBearerAuth()
+10 -2
View File
@@ -6,10 +6,18 @@ import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { MfaModule } from './mfa/mfa.module';
import { SsoModule } from './sso/sso.module';
import { SsoController } from './sso/sso.controller';
@Module({
imports: [PassportModule, JwtModule.register({}), UsersModule, MfaModule],
controllers: [AuthController],
imports: [
PassportModule,
JwtModule.register({}),
UsersModule,
MfaModule,
SsoModule,
],
controllers: [AuthController, SsoController],
providers: [AuthService, JwtStrategy],
})
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', () => {
it('throws when the refresh token is unknown', async () => {
prisma.refreshToken.findUnique.mockResolvedValue(null);
+20
View File
@@ -144,6 +144,26 @@ export class AuthService {
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> {
const { userId } = this.mfaService.verifyChallengeToken(mfaToken);
const user = await this.usersService.findById(userId);
@@ -194,6 +194,18 @@ export const PERMISSION_CATALOG = [
],
1600,
),
...group(
'Etiketten',
'label-templates',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['deactivate', 'Deaktivieren'],
['print', 'Drucken'],
],
1650,
),
...group(
'Inventar',
'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,
);
});
});
});
+138
View File
@@ -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([
queenlessRule,
varroaRule,
@@ -79,7 +79,7 @@ describe('AutomationService', () => {
const result = await service.findAll();
expect(prisma.automationRule.upsert).toHaveBeenCalledTimes(3);
expect(prisma.automationRule.upsert).toHaveBeenCalledTimes(6);
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', () => {
it('records an audit log entry with before/after data', async () => {
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
@@ -6,8 +6,12 @@ import { WebhooksService } from '../webhooks/webhooks.service';
import {
AutomationRule,
AutomationTriggerType,
Colony,
Inspection,
Prisma,
StockItem,
SwarmControlCheck,
TaskRelatedType,
VarroaMeasurement,
} from '../../generated/prisma';
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
@@ -31,6 +35,19 @@ const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
config: {},
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()
@@ -118,6 +135,7 @@ export class AutomationService {
) {
await this.triggerAction(
queenlessRule,
'COLONY',
inspection.colonyId,
inspection.id,
);
@@ -125,7 +143,12 @@ export class AutomationService {
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
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 value = measurement.calculatedValue ?? measurement.miteCount;
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(
rule: AutomationRule,
colonyId: string,
relatedType: TaskRelatedType,
relatedId: string,
sourceInspectionId?: string,
): Promise<void> {
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe';
@@ -157,8 +226,8 @@ export class AutomationService {
data: {
title,
priority: 'HIGH',
relatedType: 'COLONY',
relatedId: colonyId,
relatedType,
relatedId,
sourceInspectionId,
},
});
@@ -166,14 +235,19 @@ export class AutomationService {
await this.notifications.record({
priority: 'WARNUNG',
message: title,
relatedType: 'COLONY',
relatedId: colonyId,
relatedType,
relatedId,
});
await this.auditLog.record({
entityType: 'AutomationRule',
entityId: rule.id,
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 { AutomationModule } from '../automation/automation.module';
import { ColoniesController } from './colonies.controller';
import { ColoniesService } from './colonies.service';
@Module({
imports: [AutomationModule],
controllers: [ColoniesController],
providers: [ColoniesService],
exports: [ColoniesService],
@@ -3,6 +3,7 @@ import { ColoniesService } from './colonies.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service';
describe('ColoniesService', () => {
let service: ColoniesService;
@@ -55,10 +56,14 @@ describe('ColoniesService', () => {
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
const automation = {
evaluateColonyHealth: jest.fn().mockResolvedValue(undefined),
};
service = new ColoniesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService,
automation as unknown as AutomationService,
);
});
@@ -6,6 +6,7 @@ import {
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service';
import { Colony, ColonyStatus } from '../../generated/prisma';
import { CreateColonyDto } from './dto/create-colony.dto';
import { UpdateColonyDto } from './dto/update-colony.dto';
@@ -24,6 +25,7 @@ export class ColoniesService {
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
private readonly automation: AutomationService,
) {}
async findAll(filter: ColonyFilter = {}): Promise<Colony[]> {
@@ -80,6 +82,7 @@ export class ColoniesService {
beforeData: before,
afterData: colony,
});
await this.automation.evaluateColonyHealth(before, 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 { isAllowedAvatarMimeType } from './avatar-mime-types';
import { ChangePasswordDto } from './dto/change-password.dto';
import { DeactivateAccountDto } from './dto/deactivate-account.dto';
import { DisableMfaDto } from './dto/disable-mfa.dto';
import { MfaConfirmDto } from './dto/mfa-confirm.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')
@ApiConsumes('multipart/form-data')
@UseInterceptors(
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { MfaModule } from '../auth/mfa/mfa.module';
import { UsersModule } from '../users/users.module';
import { ProfileController } from './profile.controller';
import { ProfileService } from './profile.service';
@Module({
imports: [MfaModule],
imports: [MfaModule, UsersModule],
controllers: [ProfileController],
providers: [ProfileService],
})
@@ -11,6 +11,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { MfaService } from '../auth/mfa/mfa.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { UsersService } from '../users/users.service';
jest.mock('node:fs/promises');
@@ -29,6 +30,7 @@ describe('ProfileService', () => {
confirmEnrollment: jest.Mock;
};
let permissionResolver: { invalidateUser: jest.Mock };
let usersService: { deactivate: jest.Mock };
const baseUser = {
id: 'user-1',
@@ -52,11 +54,13 @@ describe('ProfileService', () => {
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
mfaService = { startEnrollment: jest.fn(), confirmEnrollment: jest.fn() };
permissionResolver = { invalidateUser: jest.fn() };
usersService = { deactivate: jest.fn().mockResolvedValue(undefined) };
service = new ProfileService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
mfaService as unknown as MfaService,
permissionResolver as unknown as PermissionResolverService,
usersService as unknown as UsersService,
);
(fs.mkdir 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', () => {
const file = {
buffer: Buffer.from('binary'),
@@ -13,6 +13,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { MfaService, MfaEnrollment } from '../auth/mfa/mfa.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { UsersService } from '../users/users.service';
import {
ALLOWED_AVATAR_MIME_TYPES,
mimeTypeForExtension,
@@ -41,6 +42,7 @@ export class ProfileService {
private readonly auditLog: AuditLogService,
private readonly mfaService: MfaService,
private readonly permissionResolver: PermissionResolverService,
private readonly usersService: UsersService,
) {}
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> {
if (!(file.mimetype in ALLOWED_AVATAR_MIME_TYPES)) {
throw new BadRequestException('Nicht unterstützter Dateityp');
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module';
import { StorageController } from './storage.controller';
import { StorageService } from './storage.service';
@Module({
imports: [AutomationModule],
controllers: [StorageController],
providers: [StorageService],
exports: [StorageService],
@@ -3,6 +3,7 @@ import { StorageService } from './storage.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service';
describe('StorageService', () => {
let service: StorageService;
@@ -47,10 +48,14 @@ describe('StorageService', () => {
};
auditLog = { record: jest.fn() };
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
const automation = {
evaluateStockItem: jest.fn().mockResolvedValue(undefined),
};
service = new StorageService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
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 { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service';
import { StockItem, StorageContainer } from '../../generated/prisma';
import { CreateStorageContainerDto } from './dto/create-storage-container.dto';
import { UpdateStorageContainerDto } from './dto/update-storage-container.dto';
@@ -15,6 +16,7 @@ export class StorageService {
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
private readonly automation: AutomationService,
) {}
async findAllContainers(): Promise<StorageContainer[]> {
@@ -149,6 +151,7 @@ export class StorageService {
});
const stockItem = await this.findStockItemById(id);
await this.webhooks.dispatch('product.stock_changed', stockItem);
await this.automation.evaluateStockItem(stockItem);
return stockItem;
}
}
@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module';
import { SwarmControlController } from './swarm-control.controller';
import { ColonySwarmControlChecksController } from './colony-swarm-control-checks.controller';
import { SwarmControlService } from './swarm-control.service';
@Module({
imports: [AutomationModule],
controllers: [SwarmControlController, ColonySwarmControlChecksController],
providers: [SwarmControlService],
exports: [SwarmControlService],
@@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common';
import { SwarmControlService } from './swarm-control.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { AutomationService } from '../automation/automation.service';
describe('SwarmControlService', () => {
let service: SwarmControlService;
@@ -31,9 +32,13 @@ describe('SwarmControlService', () => {
},
};
auditLog = { record: jest.fn() };
const automation = {
evaluateSwarmControlCheck: jest.fn().mockResolvedValue(undefined),
};
service = new SwarmControlService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
automation as unknown as AutomationService,
);
});
@@ -1,6 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { AutomationService } from '../automation/automation.service';
import { SwarmControlCheck } from '../../generated/prisma';
import { CreateSwarmControlCheckDto } from './dto/create-swarm-control-check.dto';
import { UpdateSwarmControlCheckDto } from './dto/update-swarm-control-check.dto';
@@ -10,6 +11,7 @@ export class SwarmControlService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly automation: AutomationService,
) {}
async findAll(): Promise<SwarmControlCheck[]> {
@@ -56,6 +58,7 @@ export class SwarmControlService {
userId: actingUserId,
afterData: check,
});
await this.automation.evaluateSwarmControlCheck(check);
return check;
}
@@ -83,6 +86,7 @@ export class SwarmControlService {
beforeData: before,
afterData: check,
});
await this.automation.evaluateSwarmControlCheck(check);
return check;
}
}
@@ -20,6 +20,7 @@ const ALLOWLIST = new Set([
'AuthController.me',
'ProfileController.updateProfile',
'ProfileController.changePassword',
'ProfileController.deactivateAccount',
'ProfileController.uploadAvatar',
'ProfileController.deleteAvatar',
'ProfileController.getAvatar',
+3 -1
View File
@@ -8,5 +8,7 @@
"transform": {
"^.+\\.(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 (
<Card className="max-w-lg">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2.5">
<span
@@ -40,12 +40,9 @@ export default function ApiaryDetailPage() {
<div className="flex justify-end">
<QrCodeDialog entityType="apiaries" id={apiary.id} />
</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} />
<WeatherCard
apiaryId={apiary.id}
hasCoordinates={apiary.latitude !== null && apiary.longitude !== null}
/>
<DocumentUploadCard entityType="APIARY" entityId={apiary.id} />
<div className="flex flex-wrap gap-2">
<Button
nativeButton={false}
@@ -63,6 +60,15 @@ export default function ApiaryDetailPage() {
: `Jahresbericht ${CURRENT_YEAR}`}
</Button>
</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>
);
}
@@ -8,6 +8,7 @@ import { fetchBottlingBatch } from "@/lib/bottling/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { QrCodeDialog } from "@/components/qr-code-dialog";
import { BottlingEditDialog } from "./bottling-edit-dialog";
import { PrintLabelsCard } from "./print-labels-card";
export default function BottlingBatchDetailPage() {
const params = useParams<{ id: string }>();
@@ -29,7 +30,8 @@ export default function BottlingBatchDetailPage() {
return (
<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">
<div className="flex items-center gap-2.5">
<span
@@ -73,6 +75,8 @@ export default function BottlingBatchDetailPage() {
</p>
</CardContent>
</Card>
<PrintLabelsCard bottlingBatchId={bottlingBatch.id} />
</div>
</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)}
>
<SelectTrigger id="split-type">
<SelectValue />
<SelectValue>{COLONY_TYPE_LABELS[colonyType]}</SelectValue>
</SelectTrigger>
<SelectContent>
{COLONY_TYPES.map((type) => (
@@ -113,7 +113,7 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
}
return (
<Card className="max-w-lg">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2.5">
<span
@@ -135,7 +135,7 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
onValueChange={(value) => setColonyType(value as ColonyType)}
>
<SelectTrigger id="colony-type">
<SelectValue />
<SelectValue>{COLONY_TYPE_LABELS[colonyType]}</SelectValue>
</SelectTrigger>
<SelectContent>
{COLONY_TYPES.map((type) => (
@@ -21,7 +21,7 @@ export function ColonyFeedingsCard({ colonyId }: { colonyId: string }) {
});
return (
<Card className="max-w-lg">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Fütterung</CardTitle>
<Button
@@ -27,7 +27,7 @@ export function ColonyHistoryCard({ colonyId }: { colonyId: string }) {
history.lineageAsResult.length > 0;
return (
<Card className="max-w-lg">
<Card>
<CardHeader>
<CardTitle>Historie</CardTitle>
</CardHeader>
@@ -16,7 +16,7 @@ export function ColonyInspectionsCard({ colonyId }: { colonyId: string }) {
});
return (
<Card className="max-w-lg">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Durchsichten</CardTitle>
<Button
@@ -18,7 +18,7 @@ export function ColonySwarmControlCard({ colonyId }: { colonyId: string }) {
});
return (
<Card className="max-w-lg">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Schwarmkontrolle</CardTitle>
<Button
@@ -16,7 +16,7 @@ export function ColonyTreatmentsCard({ colonyId }: { colonyId: string }) {
});
return (
<Card className="max-w-lg">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Behandlungen</CardTitle>
<Button
@@ -54,7 +54,7 @@ export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {
.filter((value): value is number => value !== null);
return (
<Card className="max-w-lg">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Varroa</CardTitle>
<Button
@@ -5,6 +5,12 @@ import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateInspection } from "@/lib/inspections/api";
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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -125,7 +131,7 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
onValueChange={(value) => setFeedStatus((value ?? "ausreichend") as string)}
>
<SelectTrigger id="inspection-edit-feed" className="w-full">
<SelectValue />
<SelectValue>{FEED_STATUS_LABELS[feedStatus] ?? feedStatus}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ausreichend">Ausreichend</SelectItem>
@@ -54,15 +54,23 @@ export default function ColonyDetailPage() {
</Button>
<QrCodeDialog entityType="colonies" id={colony.id} />
</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} />
<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} />
<ColonyVarroaCard colonyId={colony.id} />
<ColonySwarmControlCard colonyId={colony.id} />
<ColonyTreatmentsCard colonyId={colony.id} />
<ColonyFeedingsCard colonyId={colony.id} />
<ColonyHistoryCard colonyId={colony.id} />
<DocumentUploadCard entityType="COLONY" entityId={colony.id} />
</div>
<ColonyHistoryCard colonyId={colony.id} />
</div>
</div>
</main>
);
}
@@ -101,7 +101,7 @@ export default function NewColonyPage() {
onValueChange={(value) => setColonyType(value as ColonyType)}
>
<SelectTrigger id="colony-type">
<SelectValue />
<SelectValue>{COLONY_TYPE_LABELS[colonyType]}</SelectValue>
</SelectTrigger>
<SelectContent>
{COLONY_TYPES.map((type) => (
@@ -73,7 +73,7 @@ export function HoneyBatchStorageCard({ honeyBatch }: { honeyBatch: HoneyBatch }
}
return (
<Card className="max-w-lg">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Lagerbehälter</CardTitle>
<Dialog open={open} onOpenChange={setOpen}>
@@ -46,7 +46,9 @@ export default function HoneyBatchDetailPage() {
return (
<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">
<div className="flex items-center gap-2.5">
<span
@@ -80,8 +82,12 @@ export default function HoneyBatchDetailPage() {
</Button>
</CardContent>
</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">
<CardTitle>Honigmessungen</CardTitle>
<AddMeasurementDialog honeyBatchId={honeyBatch.id} />
@@ -100,13 +106,18 @@ export default function HoneyBatchDetailPage() {
{new Date(measurement.measuredAt).toLocaleDateString("de-DE")} ·{" "}
{measurement.waterContent}% Wassergehalt
</p>
{measurement.device ? <p className="text-muted-foreground">{measurement.device}</p> : null}
{measurement.device ? (
<p className="text-muted-foreground">{measurement.device}</p>
) : null}
</div>
))}
</CardContent>
</Card>
<Card className="max-w-lg">
<DocumentUploadCard entityType="HONEY_BATCH" entityId={honeyBatch.id} />
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Rückverfolgbarkeit</CardTitle>
<Button
@@ -133,7 +144,7 @@ export default function HoneyBatchDetailPage() {
honeyBatchId={honeyBatch.id}
/>
</div>
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{traceability.harvests.map((harvest) => (
<Link
key={harvest.id}
@@ -153,9 +164,8 @@ export default function HoneyBatchDetailPage() {
) : null}
</CardContent>
</Card>
<HoneyBatchStorageCard honeyBatch={honeyBatch} />
<DocumentUploadCard entityType="HONEY_BATCH" entityId={honeyBatch.id} />
</div>
</div>
</main>
);
}
@@ -23,6 +23,12 @@ import {
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
const FEED_STATUS_LABELS = {
ausreichend: "Ausreichend",
niedrig: "Niedrig",
kritisch: "Kritisch",
} as const;
function todayIsoDate(): string {
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">
<SelectValue />
<SelectValue>{FEED_STATUS_LABELS[feedStatus]}</SelectValue>
</SelectTrigger>
<SelectContent>
<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 { updateProfile, changePassword, uploadAvatar } from "@/lib/profile/api";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("@/lib/profile/api", () => ({
updateProfile: vi.fn(),
changePassword: vi.fn(),
@@ -15,6 +19,7 @@ vi.mock("@/lib/profile/api", () => ({
enrollMfa: vi.fn(),
confirmMfaEnrollment: vi.fn(),
disableMfa: vi.fn(),
deactivateAccount: vi.fn(),
}));
const refreshUserMock = vi.fn();
@@ -29,7 +34,7 @@ const mockUser = {
hasAvatar: false,
};
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ user: mockUser, refreshUser: refreshUserMock }),
useAuth: () => ({ user: mockUser, refreshUser: refreshUserMock, logout: vi.fn() }),
}));
function renderWithClient(ui: ReactElement) {
@@ -53,13 +58,13 @@ describe("ProfilePage", () => {
});
renderWithClient(<ProfilePage />);
const nameInput = screen.getByLabelText("Name");
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "New Name");
const lastNameInput = screen.getByLabelText("Nachname");
await userEvent.clear(lastNameInput);
await userEvent.type(lastNameInput, "Name");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(updateProfile).toHaveBeenCalledWith({
name: "New Name",
name: "Existing Name",
email: "[email protected]",
});
await vi.waitFor(() => expect(refreshUserMock).toHaveBeenCalled());
@@ -13,6 +13,7 @@ import {
} from "@/lib/profile/api";
import { PageHeader } from "@/components/page-header";
import { UserAvatar } from "@/components/user-avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -24,6 +25,12 @@ import {
CardTitle,
} from "@/components/ui/card";
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() {
const { user, refreshUser } = useAuth();
@@ -100,14 +107,21 @@ function AvatarSection() {
function ProfileInfoSection() {
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 [currentPassword, setCurrentPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const emailChanged = email !== user?.email;
const mutation = useMutation({
mutationFn: () => updateProfile({ name, email, currentPassword: currentPassword || undefined }),
mutationFn: () =>
updateProfile({
name: [firstName, lastName].filter(Boolean).join(" "),
email,
currentPassword: currentPassword || undefined,
}),
onSuccess: async () => {
setCurrentPassword("");
await refreshUser();
@@ -131,19 +145,32 @@ function ProfileInfoSection() {
<Card>
<CardHeader>
<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>
<CardContent>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="profile-name">Name</Label>
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="profile-first-name">Vorname</Label>
<Input
id="profile-name"
id="profile-first-name"
required
value={name}
onChange={(event) => setName(event.target.value)}
value={firstName}
onChange={(event) => setFirstName(event.target.value)}
/>
</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">
<Label htmlFor="profile-email">E-Mail</Label>
<Input
@@ -270,6 +297,7 @@ export default function ProfilePage() {
<ProfileInfoSection />
<PasswordSection />
<MfaSection mfaEnabled={user.mfaEnabled} onChanged={() => void refreshUser()} />
<DangerZoneSection />
</div>
</main>
);
@@ -34,12 +34,20 @@ export default function QueenDetailPage() {
<div className="flex justify-end">
<QrCodeDialog entityType="queens" id={queen.id} />
</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} />
<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} />
<QueenLineageCard queenId={queen.id} />
<QueenHistoryCard queenId={queen.id} />
<DocumentUploadCard entityType="QUEEN" entityId={queen.id} />
</div>
<QueenHistoryCard queenId={queen.id} />
</div>
</div>
</main>
);
}
@@ -61,7 +61,7 @@ export function QueenEditCard({ queen }: { queen: Queen }) {
}
return (
<Card className="max-w-lg">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2.5">
<span
@@ -78,7 +78,7 @@ export function QueenEvaluationsCard({ queenId }: { queenId: string }) {
}
return (
<Card className="max-w-lg">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Bewertung</CardTitle>
<Dialog open={open} onOpenChange={setOpen}>
@@ -22,7 +22,7 @@ export function QueenHistoryCard({ queenId }: { queenId: string }) {
}
return (
<Card className="max-w-lg">
<Card>
<CardHeader>
<CardTitle>Volk-Historie</CardTitle>
</CardHeader>
@@ -48,7 +48,7 @@ export function QueenLineageCard({ queenId }: { queenId: string }) {
const hasLineage = ancestors.length > 0 || (lineage?.descendants.length ?? 0) > 0;
return (
<Card className="max-w-lg">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<GitBranch className="size-4" />
@@ -14,18 +14,27 @@ import { PageHeader } from "@/components/page-header";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
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",
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> = {
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:
"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:
"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 {
@@ -59,7 +68,7 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
}
return (
<Card className="max-w-lg">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{TRIGGER_LABELS[rule.triggerType]}</CardTitle>
<Badge variant={rule.active ? "default" : "secondary"}>
@@ -128,9 +137,11 @@ export default function AutomationRulesPage() {
</p>
) : null}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
{rules?.map((rule) => (
<RuleCard key={rule.id} rule={rule} />
))}
</div>
</main>
);
}
@@ -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>
);
}
@@ -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,
Droplet,
FileClock,
FlaskConical,
Hexagon,
History,
LayoutDashboard,
@@ -17,9 +18,11 @@ import {
MapPin,
Package,
Repeat,
Scissors,
Search,
Shield,
ShoppingBag,
Tag,
Upload,
Users,
Warehouse,
@@ -68,6 +71,8 @@ const NAV_GROUPS: NavGroup[] = [
{
label: "Ernte & Lager",
items: [
{ label: "Ernten", href: "/harvests/new", icon: Scissors },
{ label: "Schleuderungen", href: "/extractions/new", icon: FlaskConical },
{ label: "Honigchargen", href: "/honey-batches", icon: Droplet },
{ label: "Abfüllungen", href: "/bottling/new", icon: Package },
{ label: "Produkte", href: "/products", icon: ShoppingBag },
@@ -100,6 +105,12 @@ const ADMIN_GROUP: NavGroup = {
icon: Repeat,
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" },
],
};
@@ -4,27 +4,44 @@ import userEvent from "@testing-library/user-event";
import LoginPage from "./page";
const pushMock = vi.fn();
const replaceMock = vi.fn();
const searchParamsMock = vi.fn(() => new URLSearchParams());
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
useRouter: () => ({ push: pushMock, replace: replaceMock }),
useSearchParams: () => searchParamsMock(),
}));
const loginMock = vi.fn();
const verifyMfaMock = vi.fn();
const completeSsoMock = vi.fn();
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({
login: loginMock,
verifyMfa: verifyMfaMock,
completeSso: completeSsoMock,
user: null,
isLoading: false,
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", () => {
beforeEach(() => {
pushMock.mockReset();
replaceMock.mockReset();
loginMock.mockReset();
verifyMfaMock.mockReset();
completeSsoMock.mockReset();
searchParamsMock.mockReturnValue(new URLSearchParams());
ssoStatusRequestMock.mockReset().mockResolvedValue(false);
ssoLoginUrlMock.mockClear();
});
it("submits credentials and redirects on success", async () => {
@@ -83,4 +100,22 @@ describe("LoginPage", () => {
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");
});
});
+77 -5
View File
@@ -1,9 +1,10 @@
"use client";
import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
import { FormEvent, Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Hexagon } from "lucide-react";
import { useAuth } from "@/lib/auth/auth-context";
import { ssoLoginUrl, ssoStatusRequest } from "@/lib/auth/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -16,15 +17,70 @@ import {
CardTitle,
} 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() {
return (
<Suspense fallback={null}>
<LoginPageContent />
</Suspense>
);
}
function LoginPageContent() {
const router = useRouter();
const { login, verifyMfa } = useAuth();
const searchParams = useSearchParams();
const { login, verifyMfa, completeSso } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
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 [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>) {
event.preventDefault();
@@ -82,7 +138,9 @@ export default function LoginPage() {
</CardDescription>
</CardHeader>
<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}>
<div className="flex flex-col gap-2">
<Label htmlFor="code">Code</Label>
@@ -150,6 +208,20 @@ export default function LoginPage() {
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Anmelden…" : "Anmelden"}
</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>
)}
</CardContent>
+33
View File
@@ -72,6 +72,39 @@ export function refreshRequest(): Promise<AccessTokenResponse | null> {
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> {
const apiUrl = await getApiUrl();
await fetch(`${apiUrl}/auth/logout`, { method: 'POST', credentials: 'include' });
+17 -1
View File
@@ -7,6 +7,7 @@ import {
loginRequest,
logoutRequest,
refreshRequest,
ssoCompleteRequest,
verifyMfaRequest,
} from './api';
import { getAccessToken, setAccessToken as storeAccessToken } from './token-store';
@@ -29,6 +30,7 @@ interface AuthContextValue {
isLoading: boolean;
login: (email: string, password: string) => Promise<LoginOutcome>;
verifyMfa: (mfaToken: string, code: string) => Promise<void>;
completeSso: () => Promise<void>;
logout: () => Promise<void>;
hasPermission: (permission: string) => boolean;
refreshUser: () => Promise<void>;
@@ -76,6 +78,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(await applyAccessToken(result.accessToken));
}, []);
const completeSso = useCallback(async () => {
const result = await ssoCompleteRequest();
setUser(await applyAccessToken(result.accessToken));
}, []);
const logout = useCallback(async () => {
await logoutRequest();
storeAccessToken(null);
@@ -100,7 +107,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return (
<AuthContext.Provider
value={{ user, isLoading, login, verifyMfa, logout, hasPermission, refreshUser }}
value={{
user,
isLoading,
login,
verifyMfa,
completeSso,
logout,
hasPermission,
refreshUser,
}}
>
{children}
</AuthContext.Provider>
+4 -1
View File
@@ -1,7 +1,10 @@
export type AutomationTriggerType =
| "QUEENLESS_NO_EGGS"
| "VARROA_THRESHOLD"
| "FEED_STATUS_CRITICAL";
| "FEED_STATUS_CRITICAL"
| "SWARM_MOOD_HIGH"
| "COLONY_HEALTH_CRITICAL"
| "STOCK_ITEM_LOW";
export interface AutomationRule {
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)",
},
];
+7
View File
@@ -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> {
const formData = new FormData();
formData.append("file", file);
@@ -9,3 +9,9 @@ data:
FRONTEND_ORIGIN: "https://apicore.example.com"
DOCUMENTS_STORAGE_PATH: "/data/documents"
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"
+4
View File
@@ -12,3 +12,7 @@ stringData:
DATABASE_URL: "postgresql://apicore:change-me-postgres-password@apicore-postgres:5432/apicore?schema=public"
JWT_ACCESS_SECRET: "change-me-access-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"