4 Commits

Author SHA1 Message Date
Pepe Ziberi
f9ad00a7e7 fix(journal): Modul-Button folgt dem Mandanten des offenen Einsatzes (v1.8.6)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 45m49s
Ursache: der "Module"-Button hing am Mandanten des Nutzers, nicht am Mandanten des
geöffneten Einsatzes. SERVER_ADMIN (ohne eigenen Mandant) oder mandantenfremde Einsätze
zeigten daher keinen Button.

- /api/tenant/modules: GET/PUT akzeptieren tenantId; SERVER_ADMIN darf den Mandanten des
  Einsatzes verwalten, andere nur den eigenen (canManage sauber berechnet)
- journal-view lädt/speichert Module für den Einsatz-Mandanten (projectTenantId)
- ModuleManagerDialog reicht tenantId beim Speichern durch

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:36:04 +02:00
Pepe Ziberi
aaeeee1717 fix(journal): Modul-Button & Aktionen auf dem Handy klar sichtbar (v1.8.5)
- Journal-Kopfzeile bricht auf dem Handy sauber um (Titel oben, Aktionen darunter)
- "Module"-Button trägt jetzt immer die Beschriftung (vorher nur Icon auf dem Handy)
  und ist damit auffindbar; Aktionen (Module/Rapport/E-Mail/Drucken) umbrechend statt gequetscht

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:21:29 +02:00
Pepe Ziberi
b095d9de1c fix(admin): Mobil-Layout der Administration entzerrt (v1.8.4)
- Tab-Leiste (7 Tabs) war auf dem Handy in grid-cols-7 gequetscht/überlappend →
  jetzt horizontal scrollbar (flex/overflow-x-auto), ab md wieder 7-spaltiges Grid
- Header responsiv: "Zurück"-Text nur ab sm, Name kürzt (truncate), Rollen-Badge nur ab sm,
  kleinere Paddings

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:17:31 +02:00
Pepe Ziberi
ae78b2d878 fix(nav): Sicherheit/2FA & Konto-Datenschutz im Benutzer-Menü auffindbar (v1.8.3)
2FA lag unter /settings, war aber über kein Menü erreichbar. Neu im Benutzer-Menü
(Desktop-Dropdown + Mobil-Menü): "Sicherheit & 2FA" (→ /settings) und
"Konto & Datenschutz" (→ /konto/datenschutz).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:15:40 +02:00
7 changed files with 83 additions and 41 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "lageplan", "name": "lageplan",
"version": "1.8.2", "version": "1.8.6",
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation", "description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
"private": true, "private": true,
"scripts": { "scripts": {

View File

@@ -57,33 +57,31 @@ export default function AdminPage() {
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
{/* Header */} {/* Header */}
<header className="h-14 border-b border-border bg-card flex items-center justify-between px-6"> <header className="h-14 border-b border-border bg-card flex items-center justify-between px-3 md:px-6 gap-2">
<div className="flex items-center gap-4"> <div className="flex items-center gap-2 md:gap-4 min-w-0">
<Link href="/app"> <Link href="/app">
<Button variant="ghost" size="sm"> <Button variant="ghost" size="sm" className="px-2 shrink-0">
<ArrowLeft className="w-4 h-4 mr-2" /> <ArrowLeft className="w-4 h-4 md:mr-2" />
Zurück zur App <span className="hidden sm:inline">Zurück zur App</span>
</Button> </Button>
</Link> </Link>
<div className="h-6 w-px bg-border" /> <div className="hidden sm:block h-6 w-px bg-border" />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 min-w-0">
<MapPin className="w-5 h-5 text-primary" /> <MapPin className="w-5 h-5 text-primary shrink-0" />
<h1 className="font-semibold text-lg">Administration</h1> <h1 className="font-semibold text-base md:text-lg truncate">Administration</h1>
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-2 text-sm min-w-0 shrink-0">
<div className="flex items-center gap-2 text-sm"> <Shield className="w-4 h-4 text-primary shrink-0 hidden sm:block" />
<Shield className="w-4 h-4 text-primary" /> <span className="font-medium truncate max-w-[100px] md:max-w-none">{user.name}</span>
<span className="font-medium">{user.name}</span> <span className="hidden sm:inline text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full">{user.role}</span>
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full">{user.role}</span>
</div>
</div> </div>
</header> </header>
<div className="container mx-auto py-6 px-4 max-w-7xl"> <div className="container mx-auto py-6 px-4 max-w-7xl">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6"> <Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
{isServerAdmin ? ( {isServerAdmin ? (
<TabsList className="grid w-full grid-cols-7 max-w-4xl"> <TabsList className="w-full max-w-4xl h-auto flex flex-nowrap overflow-x-auto justify-start md:grid md:grid-cols-7 [&>button]:shrink-0 [&>button]:whitespace-nowrap">
<TabsTrigger value="tenants" className="gap-2"><Shield className="w-4 h-4" />Mandanten</TabsTrigger> <TabsTrigger value="tenants" className="gap-2"><Shield className="w-4 h-4" />Mandanten</TabsTrigger>
<TabsTrigger value="projects" className="gap-2"><Map className="w-4 h-4" />Einsätze</TabsTrigger> <TabsTrigger value="projects" className="gap-2"><Map className="w-4 h-4" />Einsätze</TabsTrigger>
<TabsTrigger value="icons" className="gap-2"><ImageIcon className="w-4 h-4" />Symbole</TabsTrigger> <TabsTrigger value="icons" className="gap-2"><ImageIcon className="w-4 h-4" />Symbole</TabsTrigger>
@@ -93,7 +91,7 @@ export default function AdminPage() {
<TabsTrigger value="settings" className="gap-2"><Settings className="w-4 h-4" />System</TabsTrigger> <TabsTrigger value="settings" className="gap-2"><Settings className="w-4 h-4" />System</TabsTrigger>
</TabsList> </TabsList>
) : ( ) : (
<TabsList className="grid w-full grid-cols-7 max-w-4xl"> <TabsList className="w-full max-w-4xl h-auto flex flex-nowrap overflow-x-auto justify-start md:grid md:grid-cols-7 [&>button]:shrink-0 [&>button]:whitespace-nowrap">
<TabsTrigger value="org" className="gap-2"><Building2 className="w-4 h-4" />Organisation</TabsTrigger> <TabsTrigger value="org" className="gap-2"><Building2 className="w-4 h-4" />Organisation</TabsTrigger>
<TabsTrigger value="users" className="gap-2"><Users className="w-4 h-4" />Benutzer</TabsTrigger> <TabsTrigger value="users" className="gap-2"><Users className="w-4 h-4" />Benutzer</TabsTrigger>
<TabsTrigger value="icons" className="gap-2"><ImageIcon className="w-4 h-4" />Symbole</TabsTrigger> <TabsTrigger value="icons" className="gap-2"><ImageIcon className="w-4 h-4" />Symbole</TabsTrigger>

View File

@@ -3,26 +3,45 @@ import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { normalizeModules, DEFAULT_MODULES } from '@/lib/modules' import { normalizeModules, DEFAULT_MODULES } from '@/lib/modules'
/** Cockpit-Modul-Konfiguration des eigenen Mandanten lesen */ /**
export async function GET() { * Ermittelt den massgeblichen Mandanten für die Modul-Verwaltung.
* SERVER_ADMIN darf einen beliebigen Mandanten (z.B. den des offenen Einsatzes) verwalten;
* andere nur ihren eigenen. `canManage` sagt, ob dieser Nutzer speichern darf.
*/
function resolveTenant(user: any, requestedTenantId: string | null): { tenantId: string | null; canManage: boolean } {
const isServerAdmin = user.role === 'SERVER_ADMIN'
const isTenantAdmin = user.role === 'TENANT_ADMIN'
if (isServerAdmin) {
// SERVER_ADMIN: nimmt den angefragten Mandanten (Einsatz), sonst den eigenen (falls vorhanden)
return { tenantId: requestedTenantId || user.tenantId || null, canManage: true }
}
// Alle anderen: immer nur der eigene Mandant. Admin dieses Mandanten darf verwalten.
return { tenantId: user.tenantId || null, canManage: isTenantAdmin && !!user.tenantId }
}
/** Cockpit-Modul-Konfiguration lesen (Mandant = eigener oder, für SERVER_ADMIN, der des Einsatzes) */
export async function GET(request: NextRequest) {
try { try {
const user = await getSession() const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }) if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (!user.tenantId) { const requested = request.nextUrl.searchParams.get('tenantId')
// SERVER_ADMIN ohne Mandant: Standard-Cockpit const { tenantId, canManage } = resolveTenant(user, requested)
if (!tenantId) {
// Kein Mandant auflösbar: Standard-Cockpit, nicht verwaltbar
return NextResponse.json({ modules: DEFAULT_MODULES, canManage: false }) return NextResponse.json({ modules: DEFAULT_MODULES, canManage: false })
} }
const tenant = await (prisma as any).tenant.findUnique({ const tenant = await (prisma as any).tenant.findUnique({
where: { id: user.tenantId }, where: { id: tenantId },
select: { modulesConfig: true }, select: { modulesConfig: true },
}) })
const canManage = user.role === 'SERVER_ADMIN' || user.role === 'TENANT_ADMIN'
return NextResponse.json({ return NextResponse.json({
modules: normalizeModules(tenant?.modulesConfig), modules: normalizeModules(tenant?.modulesConfig),
canManage, canManage,
tenantId,
}) })
} catch (error) { } catch (error) {
console.error('Error fetching module config:', error) console.error('Error fetching module config:', error)
@@ -30,22 +49,23 @@ export async function GET() {
} }
} }
/** Cockpit-Modul-Konfiguration speichern (nur Admins) */ /** Cockpit-Modul-Konfiguration speichern (nur Admins des betroffenen Mandanten) */
export async function PUT(request: NextRequest) { export async function PUT(request: NextRequest) {
try { try {
const user = await getSession() const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }) if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (!user.tenantId) return NextResponse.json({ error: 'Kein Mandant zugeordnet' }, { status: 400 })
if (user.role !== 'SERVER_ADMIN' && user.role !== 'TENANT_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const body = await request.json() const body = await request.json()
const { tenantId, canManage } = resolveTenant(user, body?.tenantId || null)
if (!tenantId) return NextResponse.json({ error: 'Kein Mandant zugeordnet' }, { status: 400 })
if (!canManage) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
// normalizeModules verwirft Unbekanntes und stellt eingebaute Module sicher // normalizeModules verwirft Unbekanntes und stellt eingebaute Module sicher
const modules = normalizeModules(body?.modules) const modules = normalizeModules(body?.modules)
await (prisma as any).tenant.update({ await (prisma as any).tenant.update({
where: { id: user.tenantId }, where: { id: tenantId },
data: { modulesConfig: modules }, data: { modulesConfig: modules },
}) })

View File

@@ -1165,6 +1165,7 @@ export default function AppPage() {
einsatzNr={(currentProject as any)?.einsatzNr || ''} einsatzNr={(currentProject as any)?.einsatzNr || ''}
canEdit={canEditJournal} canEdit={canEditJournal}
tenantId={tenant?.id || null} tenantId={tenant?.id || null}
projectTenantId={(currentProject as any)?.tenantId ?? null}
tenantName={tenant?.name || ''} tenantName={tenant?.name || ''}
tenantLogoUrl={tenant?.id ? `/api/admin/tenants/${tenant.id}/logo/serve` : null} tenantLogoUrl={tenant?.id ? `/api/admin/tenants/${tenant.id}/logo/serve` : null}
mapRef={mapRef} mapRef={mapRef}

View File

@@ -26,6 +26,8 @@ interface JournalViewProps {
journalfuehrer: string journalfuehrer: string
canEdit: boolean canEdit: boolean
tenantId?: string | null tenantId?: string | null
/** Mandant des OFFENEN Einsatzes (kann von dem des Nutzers abweichen — z.B. SERVER_ADMIN). */
projectTenantId?: string | null
einsatzNr?: string einsatzNr?: string
tenantName?: string tenantName?: string
tenantLogoUrl?: string | null tenantLogoUrl?: string | null
@@ -67,7 +69,7 @@ function ModuleCard({ icon, name, count, children }: {
) )
} }
export function JournalView({ projectId, projectTitle, projectLocation, mode, einsatzleiter, journalfuehrer, canEdit, tenantId, einsatzNr, tenantName, tenantLogoUrl, mapRef, mapScreenshot: preCapuredScreenshot }: JournalViewProps) { export function JournalView({ projectId, projectTitle, projectLocation, mode, einsatzleiter, journalfuehrer, canEdit, tenantId, projectTenantId, einsatzNr, tenantName, tenantLogoUrl, mapRef, mapScreenshot: preCapuredScreenshot }: JournalViewProps) {
const isUebung = mode === 'UEBUNG' const isUebung = mode === 'UEBUNG'
const [entries, setEntries] = useState<JournalEntry[]>([]) const [entries, setEntries] = useState<JournalEntry[]>([])
const [checkItems, setCheckItems] = useState<JournalCheckItem[]>([]) const [checkItems, setCheckItems] = useState<JournalCheckItem[]>([])
@@ -145,16 +147,18 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
} }
}, [projectId]) }, [projectId])
// Cockpit-Modul-Konfiguration laden // Cockpit-Modul-Konfiguration laden — Mandant des OFFENEN Einsatzes (Fallback: eigener)
const modulesTenantId = projectTenantId || tenantId || null
useEffect(() => { useEffect(() => {
fetch('/api/tenant/modules') const url = modulesTenantId ? `/api/tenant/modules?tenantId=${encodeURIComponent(modulesTenantId)}` : '/api/tenant/modules'
fetch(url)
.then(r => r.ok ? r.json() : null) .then(r => r.ok ? r.json() : null)
.then(data => { .then(data => {
if (data?.modules) setModules(data.modules) if (data?.modules) setModules(data.modules)
if (data) setCanManageModules(!!data.canManage) if (data) setCanManageModules(!!data.canManage)
}) })
.catch(() => {}) .catch(() => {})
}, [tenantId]) }, [modulesTenantId])
// Init check items from templates if none exist (guarded against double-call) // Init check items from templates if none exist (guarded against double-call)
const initCheckItems = useCallback(async () => { const initCheckItems = useCallback(async () => {
@@ -516,16 +520,16 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
<div className="h-full overflow-auto bg-stone-50 dark:bg-background print:bg-white print:overflow-visible"> <div className="h-full overflow-auto bg-stone-50 dark:bg-background print:bg-white print:overflow-visible">
{/* Header */} {/* Header */}
<div className="p-3 md:p-4 border-b-2 border-red-600 dark:border-red-800 bg-white dark:bg-card shadow-sm print:bg-white print:p-2 print:border-b-2 print:border-red-600"> <div className="p-3 md:p-4 border-b-2 border-red-600 dark:border-red-800 bg-white dark:bg-card shadow-sm print:bg-white print:p-2 print:border-b-2 print:border-red-600">
<div className="flex items-center justify-between mb-2 print:mb-1"> <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-2 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" />
{isUebung ? 'Übungs-Journal' : 'Einsatz-Journal'} {isUebung ? 'Übungs-Journal' : 'Einsatz-Journal'}
</h2> </h2>
<div className="flex gap-1.5 print:hidden"> <div className="flex flex-wrap gap-1.5 print:hidden">
{canManageModules && ( {canManageModules && (
<Button variant="outline" size="sm" onClick={() => setShowModuleManager(true)} title="Cockpit-Module verwalten"> <Button variant="outline" size="sm" onClick={() => setShowModuleManager(true)} title="Cockpit-Module verwalten (Spalten, eigene Module)">
<Blocks className="w-4 h-4 md:mr-1.5" /> <Blocks className="w-4 h-4 mr-1.5" />
<span className="hidden md:inline">Module</span> Module
</Button> </Button>
)} )}
<Button <Button
@@ -847,6 +851,7 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
open={showModuleManager} open={showModuleManager}
onOpenChange={setShowModuleManager} onOpenChange={setShowModuleManager}
modules={modules} modules={modules}
tenantId={modulesTenantId}
onSaved={setModules} onSaved={setModules}
/> />

View File

@@ -16,6 +16,8 @@ interface ModuleManagerDialogProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
modules: CockpitModule[] modules: CockpitModule[]
/** Mandant, dessen Module gespeichert werden (Einsatz-Mandant; für SERVER_ADMIN relevant). */
tenantId?: string | null
onSaved: (modules: CockpitModule[]) => void onSaved: (modules: CockpitModule[]) => void
} }
@@ -25,7 +27,7 @@ interface ModuleManagerDialogProps {
* aus Vorlagen erstellen (Checkliste, Atemschutz, Kräfte, Lagemeldungen, leer). * aus Vorlagen erstellen (Checkliste, Atemschutz, Kräfte, Lagemeldungen, leer).
* Gespeichert wird pro Mandant (gilt für alle Einsätze). * Gespeichert wird pro Mandant (gilt für alle Einsätze).
*/ */
export function ModuleManagerDialog({ open, onOpenChange, modules, onSaved }: ModuleManagerDialogProps) { export function ModuleManagerDialog({ open, onOpenChange, modules, tenantId, onSaved }: ModuleManagerDialogProps) {
const [working, setWorking] = useState<CockpitModule[]>(modules) const [working, setWorking] = useState<CockpitModule[]>(modules)
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -133,7 +135,7 @@ export function ModuleManagerDialog({ open, onOpenChange, modules, onSaved }: Mo
const res = await fetch('/api/tenant/modules', { const res = await fetch('/api/tenant/modules', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ modules: working }), body: JSON.stringify({ modules: working, tenantId: tenantId || undefined }),
}) })
const data = await res.json() const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Speichern fehlgeschlagen') if (!res.ok) throw new Error(data.error || 'Speichern fehlgeschlagen')

View File

@@ -370,6 +370,14 @@ export function Topbar({
<Key className="w-4 h-4 mr-2" /> <Key className="w-4 h-4 mr-2" />
Kennwort ändern Kennwort ändern
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => window.location.href = '/settings'}>
<Lock className="w-4 h-4 mr-2" />
Sicherheit &amp; 2FA
</DropdownMenuItem>
<DropdownMenuItem onClick={() => window.location.href = '/konto/datenschutz'}>
<Shield className="w-4 h-4 mr-2" />
Konto &amp; Datenschutz
</DropdownMenuItem>
{(userRole === 'SERVER_ADMIN' || userRole === 'TENANT_ADMIN') && ( {(userRole === 'SERVER_ADMIN' || userRole === 'TENANT_ADMIN') && (
<DropdownMenuItem onClick={() => window.location.href = '/admin'}> <DropdownMenuItem onClick={() => window.location.href = '/admin'}>
<Shield className="w-4 h-4 mr-2" /> <Shield className="w-4 h-4 mr-2" />
@@ -492,6 +500,14 @@ export function Topbar({
<Key className="w-4 h-4 mr-2" /> <Key className="w-4 h-4 mr-2" />
Kennwort ändern Kennwort ändern
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => window.location.href = '/settings'}>
<Lock className="w-4 h-4 mr-2" />
Sicherheit &amp; 2FA
</DropdownMenuItem>
<DropdownMenuItem onClick={() => window.location.href = '/konto/datenschutz'}>
<Shield className="w-4 h-4 mr-2" />
Konto &amp; Datenschutz
</DropdownMenuItem>
{onLogout && ( {onLogout && (
<DropdownMenuItem onClick={onLogout} className="text-destructive focus:text-destructive"> <DropdownMenuItem onClick={onLogout} className="text-destructive focus:text-destructive">
<LogOut className="w-4 h-4 mr-2" /> <LogOut className="w-4 h-4 mr-2" />