2 Commits

Author SHA1 Message Date
Pepe Ziberi
1641101b73 feat(projects): Einsatz/Übung-Modus
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 22m24s
- Feld mode (EINSATZ/UEBUNG) am Projekt + idempotente Auto-Migration
- Auswahl beim Erstellen (Segmented-Control mit Lucide-Icons)
- Badge "Einsatz"/"Übung" in der Projektliste
- Eigene Nummerierung für Übungen (Ü-YYYY-NNNN, getrennt gezählt)
- Journal-Titel wird bei Übung zu "Übungs-Journal"
- ROADMAP.md mit Verbesserungs-Fahrplan ergänzt

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 20:12:07 +02:00
Pepe Ziberi
93af663dd1 fix(auth): JWT-Secret härten und Session-Laufzeit verkürzen
- Gemeinsames Secret-Modul (jwt-secret.ts): bricht in Produktion hart ab,
  wenn NEXTAUTH_SECRET fehlt oder <32 Zeichen – kein öffentlicher Fallback mehr
- auth.ts und middleware.ts nutzen dieselbe Secret-Quelle (kein Divergieren)
- Schwacher Compose-Fallback entfernt: Deployment ohne Secret bricht ab (:?)
- "Angemeldet bleiben" 30 → 14 Tage; Laufzeiten zentralisiert (SESSION_MAX_AGE_*)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 20:11:56 +02:00
16 changed files with 173 additions and 26 deletions

52
ROADMAP.md Normal file
View File

@@ -0,0 +1,52 @@
# Lageplan Verbesserungs-Fahrplan
Priorisierter Plan zur Weiterentwicklung. Wir arbeiten ihn Punkt für Punkt ab.
Legende Aufwand: 🟢 klein · 🟡 mittel · 🔴 gross
---
## Phase 0 Fundament & Sicherheit
- [x] **0.1 JWT-Secret härten** 🟢 ✅
- Gemeinsames Secret-Modul; in Produktion hart fehlschlagen, wenn `NEXTAUTH_SECRET`
fehlt oder < 32 Zeichen. Kein öffentlich bekannter Fallback mehr.
- Betrifft: `src/lib/auth.ts`, `src/middleware.ts`
- _Hinweis: Cookie-Flags (httpOnly/secure/sameSite) sind bereits korrekt._
- [x] **0.2 Token-Laufzeit verkürzt** 🟢 ✅
- „Angemeldet bleiben" 30 → 14 Tage. Laufzeiten zentralisiert (`SESSION_MAX_AGE_*` in `auth.ts`).
- _Offen für später: automatische Verlängerung bei Aktivität + serverseitiger Widerruf (grösserer Umbau, braucht Live-Test)._
- [ ] **0.3 Tenant-Admin-UX** 🟡
- Mitglieder einladen / Rolle ändern / deaktivieren. APIs existieren bereits.
## Phase 1 Einsatz vs. Übung
- [x] **1.1 `mode`-Feld am Projekt** 🟢 ✅
- Feld `mode` (EINSATZ/UEBUNG) im Schema + Auto-Migration + Validierung.
- Auswahl beim Erstellen (Segmented-Control), Badge in Projektliste, eigene Nummer `Ü-…`.
- [ ] **1.2 UI je nach Modus** 🟡 _(teilweise: Journal-Titel wird bei Übung zu „Übungs-Journal")_
- Offen: Übung → Journal ganz aus, stattdessen „Übungsziele / Zielerreichung / Auswertung"-Panel.
## Phase 2 Symbole aufräumen & ergänzen
- [ ] **2.1 Kategorien vereinheitlichen** 🟢 (Motorspritze, Hydrant, Leitern …)
- [ ] **2.2 Fehlende taktische Zeichen** 🟡 (Warteraum, Rettungsachse … Input von Fabian)
- [ ] **2.3 Symbol-Schönheitsfehler** 🟢 (Eingang/Treppe Umriss, Absperrung Seitenlinien)
- [ ] **2.4 Custom-Felder pro Symbol** 🟢 (z. B. Stockwerke; `Item.properties` existiert)
## Phase 3 Lage-/Rapportansicht
- [ ] **3.1 Reduzierte Lage-View** 🟡
- Read-Only (Pendenzen + Stand + Aufgaben) für Grossbildschirm. `Rapport` + Token existieren.
## Phase 4 Echte Kollaboration
- [ ] **4.1 Karte & Journal getrennt live-editierbar** 🔴
- Single-Editor-Lock aufbrechen; Socket.io-Sync erweitern.
## Phase 5 Kür
- [ ] **5.1 Linien-Typ-Abfrage** 🟡 (Rettungsachse / Leitung / normal)
---
_Fortschritt wird hier laufend abgehakt._

View File

@@ -90,7 +90,7 @@ services:
environment: environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-lageplan}:${POSTGRES_PASSWORD:-lageplan_secret}@db:5432/${POSTGRES_DB:-lageplan} DATABASE_URL: postgresql://${POSTGRES_USER:-lageplan}:${POSTGRES_PASSWORD:-lageplan_secret}@db:5432/${POSTGRES_DB:-lageplan}
NEXTAUTH_URL: ${NEXTAUTH_URL:-https://localhost:3000} NEXTAUTH_URL: ${NEXTAUTH_URL:-https://localhost:3000}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-super-secret-key-change-in-production} NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?NEXTAUTH_SECRET muss gesetzt sein (mind. 32 Zeichen) - z.B. via stack.env}
MINIO_ENDPOINT: minio MINIO_ENDPOINT: minio
MINIO_PORT: "9000" MINIO_PORT: "9000"
MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin} MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin}

View File

@@ -66,7 +66,7 @@ services:
environment: environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-lageplan}:${POSTGRES_PASSWORD:-lageplan_secret}@db:5432/${POSTGRES_DB:-lageplan} DATABASE_URL: postgresql://${POSTGRES_USER:-lageplan}:${POSTGRES_PASSWORD:-lageplan_secret}@db:5432/${POSTGRES_DB:-lageplan}
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000} NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-super-secret-key-change-in-production} NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?NEXTAUTH_SECRET muss gesetzt sein (mind. 32 Zeichen) - z.B. via .env oder stack.env}
MINIO_ENDPOINT: minio MINIO_ENDPOINT: minio
MINIO_PORT: 9000 MINIO_PORT: 9000
MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin} MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin}

View File

@@ -70,6 +70,7 @@ async function migrate() {
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "planBounds" JSONB`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "planBounds" JSONB`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "einsatzleiter" TEXT`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "einsatzleiter" TEXT`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "journalfuehrer" TEXT`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "journalfuehrer" TEXT`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "mode" TEXT NOT NULL DEFAULT 'EINSATZ'`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingById" TEXT`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingById" TEXT`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingUserName" TEXT`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingUserName" TEXT`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingSessionId" TEXT`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingSessionId" TEXT`,

View File

@@ -151,6 +151,9 @@ model TenantMembership {
model Project { model Project {
id String @id @default(uuid()) id String @id @default(uuid())
einsatzNr String? einsatzNr String?
// "EINSATZ" = echter Einsatz (mit Journal), "UEBUNG" = Übung (Auswertung statt Journal).
// Als String (nicht Enum) gehalten, damit die idempotente Raw-SQL-Migration einfach bleibt.
mode String @default("EINSATZ")
title String title String
location String? location String?
description String? description String?

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import { login, createToken } from '@/lib/auth' import { login, createToken, SESSION_MAX_AGE_SECONDS, SESSION_MAX_AGE_REMEMBER_SECONDS } from '@/lib/auth'
import { loginSchema } from '@/lib/validations' import { loginSchema } from '@/lib/validations'
import { prisma } from '@/lib/db' import { prisma } from '@/lib/db'
import { loginLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit' import { loginLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
@@ -50,7 +50,7 @@ export async function POST(request: NextRequest) {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', sameSite: 'lax',
maxAge: rememberMe ? 60 * 60 * 24 * 30 : 60 * 60 * 24, // 30 days or 24 hours maxAge: rememberMe ? SESSION_MAX_AGE_REMEMBER_SECONDS : SESSION_MAX_AGE_SECONDS, // 14 Tage oder 24 Stunden
path: '/', path: '/',
}) })

View File

@@ -55,9 +55,11 @@ export async function POST(request: NextRequest) {
) )
} }
// Generate unique Einsatz-Nr: E-YYYY-NNNN (auto-increment per tenant per year) // Generate unique number per tenant/year/mode.
// Einsatz: E-YYYY-NNNN · Übung: Ü-YYYY-NNNN (getrennte Zählung je Modus)
const mode = validated.data.mode === 'UEBUNG' ? 'UEBUNG' : 'EINSATZ'
const year = new Date().getFullYear() const year = new Date().getFullYear()
const einsatzPrefix = `E-${year}-` const einsatzPrefix = `${mode === 'UEBUNG' ? 'Ü' : 'E'}-${year}-`
let einsatzNr = `${einsatzPrefix}0001` let einsatzNr = `${einsatzPrefix}0001`
try { try {
const lastProject = await (prisma as any).project.findFirst({ const lastProject = await (prisma as any).project.findFirst({
@@ -78,6 +80,7 @@ export async function POST(request: NextRequest) {
const project = await (prisma as any).project.create({ const project = await (prisma as any).project.create({
data: { data: {
...validated.data, ...validated.data,
mode,
einsatzNr, einsatzNr,
ownerId: user.id, ownerId: user.id,
tenantId: user.tenantId || null, tenantId: user.tenantId || null,

View File

@@ -534,6 +534,7 @@ export default function AppPage() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
title: currentProject.title, title: currentProject.title,
mode: currentProject.mode,
location: currentProject.location || undefined, location: currentProject.location || undefined,
description: currentProject.description || undefined, description: currentProject.description || undefined,
einsatzleiter: currentProject.einsatzleiter || undefined, einsatzleiter: currentProject.einsatzleiter || undefined,
@@ -929,6 +930,7 @@ export default function AppPage() {
projectId={currentProject?.id || null} projectId={currentProject?.id || null}
projectTitle={currentProject?.title || ''} projectTitle={currentProject?.title || ''}
projectLocation={currentProject?.location || ''} projectLocation={currentProject?.location || ''}
mode={currentProject?.mode}
einsatzleiter={currentProject?.einsatzleiter || ''} einsatzleiter={currentProject?.einsatzleiter || ''}
journalfuehrer={currentProject?.journalfuehrer || ''} journalfuehrer={currentProject?.journalfuehrer || ''}
einsatzNr={(currentProject as any)?.einsatzNr || ''} einsatzNr={(currentProject as any)?.einsatzNr || ''}

View File

@@ -12,8 +12,8 @@ import {
DialogFooter, DialogFooter,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { MapPin, Loader2, X } from 'lucide-react' import { MapPin, Loader2, X, Flame, GraduationCap } from 'lucide-react'
import type { Project } from '@/types' import type { Project, ProjectMode } from '@/types'
interface NominatimResult { interface NominatimResult {
place_id: number place_id: number
@@ -44,6 +44,7 @@ export function ProjectDialog({
onOpenChange, onOpenChange,
onProjectCreated, onProjectCreated,
}: ProjectDialogProps) { }: ProjectDialogProps) {
const [mode, setMode] = useState<ProjectMode>('EINSATZ')
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [location, setLocation] = useState('') const [location, setLocation] = useState('')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
@@ -142,6 +143,7 @@ export function ProjectDialog({
try { try {
const body: any = { const body: any = {
title: title.trim(), title: title.trim(),
mode,
location: location.trim() || undefined, location: location.trim() || undefined,
description: description.trim() || undefined, description: description.trim() || undefined,
einsatzleiter: einsatzleiter.trim() || undefined, einsatzleiter: einsatzleiter.trim() || undefined,
@@ -169,6 +171,7 @@ export function ProjectDialog({
onProjectCreated(data.project) onProjectCreated(data.project)
// Reset form // Reset form
setMode('EINSATZ')
setTitle('') setTitle('')
setLocation('') setLocation('')
setDescription('') setDescription('')
@@ -189,6 +192,7 @@ export function ProjectDialog({
const handleClose = () => { const handleClose = () => {
if (!isCreating) { if (!isCreating) {
setMode('EINSATZ')
setTitle('') setTitle('')
setLocation('') setLocation('')
setDescription('') setDescription('')
@@ -200,21 +204,59 @@ export function ProjectDialog({
} }
} }
const isUebung = mode === 'UEBUNG'
return ( return (
<Dialog open={open} onOpenChange={handleClose}> <Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Neuer Einsatz</DialogTitle> <DialogTitle>{isUebung ? 'Neue Übung' : 'Neuer Einsatz'}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
{/* Modus-Auswahl: Einsatz vs. Übung */}
<div className="space-y-2">
<Label>Art</Label>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setMode('EINSATZ')}
disabled={isCreating}
className={`flex items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm font-medium transition-colors ${
!isUebung
? 'border-red-500 bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-300'
: 'border-input text-muted-foreground hover:bg-accent'
}`}
>
<Flame className="w-4 h-4" /> Einsatz
</button>
<button
type="button"
onClick={() => setMode('UEBUNG')}
disabled={isCreating}
className={`flex items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm font-medium transition-colors ${
isUebung
? 'border-blue-500 bg-blue-50 text-blue-700 dark:bg-blue-950/40 dark:text-blue-300'
: 'border-input text-muted-foreground hover:bg-accent'
}`}
>
<GraduationCap className="w-4 h-4" /> Übung
</button>
</div>
<p className="text-xs text-muted-foreground">
{isUebung
? 'Übung: zum Vorbereiten und Krokieren von Szenarien.'
: 'Einsatz: echte Lage mit Einsatz-Journal.'}
</p>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="project-title">Titel *</Label> <Label htmlFor="project-title">Titel *</Label>
<Input <Input
id="project-title" id="project-title"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="z.B. Wohnungsbrand Musterstrasse" placeholder={isUebung ? 'z.B. Übung Hauptstrasse — Zimmerbrand' : 'z.B. Wohnungsbrand Musterstrasse'}
disabled={isCreating} disabled={isCreating}
/> />
</div> </div>

View File

@@ -44,6 +44,7 @@ interface JournalViewProps {
projectId: string | null projectId: string | null
projectTitle: string projectTitle: string
projectLocation: string projectLocation: string
mode?: 'EINSATZ' | 'UEBUNG'
einsatzleiter: string einsatzleiter: string
journalfuehrer: string journalfuehrer: string
canEdit: boolean canEdit: boolean
@@ -65,7 +66,8 @@ function formatDateTime(dateStr: string) {
d.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' }) d.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
} }
export function JournalView({ projectId, projectTitle, projectLocation, einsatzleiter, journalfuehrer, canEdit, tenantId, einsatzNr, tenantName, tenantLogoUrl, mapRef, mapScreenshot: preCapuredScreenshot }: JournalViewProps) { export function JournalView({ projectId, projectTitle, projectLocation, mode, einsatzleiter, journalfuehrer, canEdit, tenantId, einsatzNr, tenantName, tenantLogoUrl, mapRef, mapScreenshot: preCapuredScreenshot }: JournalViewProps) {
const isUebung = mode === 'UEBUNG'
const [entries, setEntries] = useState<JournalEntry[]>([]) const [entries, setEntries] = useState<JournalEntry[]>([])
const [checkItems, setCheckItems] = useState<JournalCheckItem[]>([]) const [checkItems, setCheckItems] = useState<JournalCheckItem[]>([])
const [pendenzen, setPendenzen] = useState<JournalPendenz[]>([]) const [pendenzen, setPendenzen] = useState<JournalPendenz[]>([])
@@ -432,7 +434,7 @@ export function JournalView({ projectId, projectTitle, projectLocation, einsatzl
<div className="flex items-center justify-between mb-2 print:mb-1"> <div className="flex items-center justify-between mb-2 print:mb-1">
<h2 className="text-lg md:text-xl font-bold flex items-center gap-2 print:text-base text-red-800 dark:text-red-400"> <h2 className="text-lg md:text-xl font-bold flex items-center gap-2 print:text-base text-red-800 dark:text-red-400">
<ClipboardList className="w-5 h-5 md:w-6 md:h-6 print:w-4 print:h-4" /> <ClipboardList className="w-5 h-5 md:w-6 md:h-6 print:w-4 print:h-4" />
Einsatz-Journal {isUebung ? 'Übungs-Journal' : 'Einsatz-Journal'}
</h2> </h2>
<div className="flex gap-1.5 print:hidden"> <div className="flex gap-1.5 print:hidden">
<Button <Button

View File

@@ -423,6 +423,11 @@ export function Topbar({
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h4 className="font-medium">{p.title}</h4> <h4 className="font-medium">{p.title}</h4>
{(p as any).mode === 'UEBUNG' ? (
<span className="text-xs bg-blue-100 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300 px-1.5 py-0.5 rounded">Übung</span>
) : (
<span className="text-xs bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300 px-1.5 py-0.5 rounded">Einsatz</span>
)}
{project?.id === p.id && ( {project?.id === p.id && (
<span className="text-xs bg-primary text-primary-foreground px-1.5 py-0.5 rounded">Aktiv</span> <span className="text-xs bg-primary text-primary-foreground px-1.5 py-0.5 rounded">Aktiv</span>
)} )}

View File

@@ -2,14 +2,7 @@ import { SignJWT, jwtVerify } from 'jose'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import { prisma } from './db' import { prisma } from './db'
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
import { JWT_SECRET } from './jwt-secret'
const secretValue = process.env.NEXTAUTH_SECRET
if (!secretValue || secretValue.length < 32) {
console.warn('[AUTH] WARNING: NEXTAUTH_SECRET is missing or too short (<32 chars). Set a strong secret in production!')
}
const JWT_SECRET = new TextEncoder().encode(
secretValue || 'dev-only-fallback-do-not-use-in-production-' + Date.now()
)
export interface UserPayload { export interface UserPayload {
id: string id: string
@@ -21,11 +14,17 @@ export interface UserPayload {
emailVerified?: boolean emailVerified?: boolean
} }
// Zentrale Session-Laufzeiten (Sekunden) EINE Quelle für Token-Ablauf UND Cookie-maxAge,
// damit beide nie auseinanderdriften.
export const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 // 24 Stunden (Standard)
export const SESSION_MAX_AGE_REMEMBER_SECONDS = 60 * 60 * 24 * 14 // 14 Tage ("angemeldet bleiben")
export async function createToken(user: UserPayload, rememberMe = false): Promise<string> { export async function createToken(user: UserPayload, rememberMe = false): Promise<string> {
const maxAge = rememberMe ? SESSION_MAX_AGE_REMEMBER_SECONDS : SESSION_MAX_AGE_SECONDS
return await new SignJWT({ user }) return await new SignJWT({ user })
.setProtectedHeader({ alg: 'HS256' }) .setProtectedHeader({ alg: 'HS256' })
.setIssuedAt() .setIssuedAt()
.setExpirationTime(rememberMe ? '30d' : '24h') .setExpirationTime(`${maxAge}s`)
.sign(JWT_SECRET) .sign(JWT_SECRET)
} }

34
src/lib/jwt-secret.ts Normal file
View File

@@ -0,0 +1,34 @@
/**
* Zentrale, Edge-sichere Quelle für das JWT-Signaturgeheimnis.
*
* Wird sowohl von der API (src/lib/auth.ts, Node-Runtime) als auch von der
* Middleware (Edge-Runtime) importiert. Deshalb hier KEINE Node-/Prisma-Imports.
*
* Sicherheit: In Produktion wird hart abgebrochen, wenn kein starkes Geheimnis
* gesetzt ist so kann niemals still mit einem öffentlich bekannten Fallback
* signiert werden (Token-Fälschung).
*/
const secretValue = process.env.NEXTAUTH_SECRET
// Fester Dev-Fallback (>= 32 Zeichen), damit API und Middleware in der lokalen
// Entwicklung ohne gesetztes Secret DIESELBEN Tokens verifizieren können.
const DEV_FALLBACK = 'dev-only-fallback-do-not-use-in-production-change-me'
if (process.env.NODE_ENV === 'production') {
if (!secretValue || secretValue.length < 32) {
throw new Error(
'[AUTH] NEXTAUTH_SECRET fehlt oder ist kürzer als 32 Zeichen. ' +
'In Produktion ist ein starkes Geheimnis zwingend erforderlich.'
)
}
} else if (!secretValue || secretValue.length < 32) {
console.warn(
'[AUTH] WARNUNG: NEXTAUTH_SECRET fehlt/zu kurz nutze unsicheren Dev-Fallback. ' +
'Für Produktion ein starkes Geheimnis (>= 32 Zeichen) setzen!'
)
}
export const JWT_SECRET = new TextEncoder().encode(
secretValue && secretValue.length >= 32 ? secretValue : DEV_FALLBACK
)

View File

@@ -5,8 +5,12 @@ export const loginSchema = z.object({
password: z.string().min(1, 'Passwort erforderlich'), password: z.string().min(1, 'Passwort erforderlich'),
}) })
export const PROJECT_MODES = ['EINSATZ', 'UEBUNG'] as const
export type ProjectMode = (typeof PROJECT_MODES)[number]
export const projectSchema = z.object({ export const projectSchema = z.object({
title: z.string().min(1, 'Titel erforderlich').max(200, 'Titel zu lang'), title: z.string().min(1, 'Titel erforderlich').max(200, 'Titel zu lang'),
mode: z.enum(PROJECT_MODES).optional(),
location: z.string().optional(), location: z.string().optional(),
description: z.string().optional(), description: z.string().optional(),
einsatzleiter: z.string().optional(), einsatzleiter: z.string().optional(),

View File

@@ -1,9 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { jwtVerify } from 'jose' import { jwtVerify } from 'jose'
import { JWT_SECRET } from './lib/jwt-secret'
const JWT_SECRET = new TextEncoder().encode(
process.env.NEXTAUTH_SECRET || 'dev-only-fallback-do-not-use-in-production'
)
// Routes that require authentication // Routes that require authentication
const PROTECTED_ROUTES = ['/app', '/settings', '/admin'] const PROTECTED_ROUTES = ['/app', '/settings', '/admin']

View File

@@ -1,6 +1,9 @@
export type ProjectMode = 'EINSATZ' | 'UEBUNG'
export interface Project { export interface Project {
id: string id: string
title: string title: string
mode?: ProjectMode
location?: string location?: string
description?: string description?: string
einsatzleiter?: string einsatzleiter?: string