3 Commits

6 changed files with 114 additions and 76 deletions

View File

@@ -51,7 +51,7 @@ const nextConfig = {
"style-src 'self' 'unsafe-inline'", "style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https://*.tile.openstreetmap.org https://api.maptiler.com https://server.arcgisonline.com https://*.geo.admin.ch http://localhost:9000 http://minio:9000", "img-src 'self' data: blob: https://*.tile.openstreetmap.org https://api.maptiler.com https://server.arcgisonline.com https://*.geo.admin.ch http://localhost:9000 http://minio:9000",
"font-src 'self' data:", "font-src 'self' data:",
"connect-src 'self' ws: wss: https://api.maptiler.com https://*.tile.openstreetmap.org https://api.open-meteo.com https://server.arcgisonline.com https://*.geo.admin.ch", "connect-src 'self' ws: wss: https://api.maptiler.com https://*.tile.openstreetmap.org https://nominatim.openstreetmap.org https://api.open-meteo.com https://server.arcgisonline.com https://*.geo.admin.ch",
"frame-ancestors 'self'", "frame-ancestors 'self'",
"base-uri 'self'", "base-uri 'self'",
"form-action 'self'", "form-action 'self'",

View File

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

View File

@@ -250,8 +250,8 @@ export default function AdminPage() {
}, [authLoading, user, router]) }, [authLoading, user, router])
useEffect(() => { useEffect(() => {
fetchData() if (user?.role) fetchData()
}, []) }, [user?.role])
// Load journal suggestions when tenant is available // Load journal suggestions when tenant is available
useEffect(() => { useEffect(() => {
@@ -330,23 +330,39 @@ export default function AdminPage() {
const fetchData = async () => { const fetchData = async () => {
setIsLoading(true) setIsLoading(true)
try { try {
const [catRes, iconRes, userRes, tenantRes, projRes] = await Promise.all([ const isServerAdmin = user?.role === 'SERVER_ADMIN'
// Common fetches for all admins
const fetches: Promise<Response>[] = [
fetch('/api/admin/categories'), fetch('/api/admin/categories'),
fetch('/api/admin/icons'),
fetch('/api/admin/users'), fetch('/api/admin/users'),
fetch('/api/admin/tenants'),
fetch('/api/projects'), fetch('/api/projects'),
]) ]
// SERVER_ADMIN-only fetches
if (isServerAdmin) {
fetches.push(fetch('/api/admin/icons'))
fetches.push(fetch('/api/admin/tenants'))
}
const results = await Promise.all(fetches)
const [catRes, userRes, projRes] = results
if (catRes.ok) setCategories((await catRes.json()).categories || []) if (catRes.ok) setCategories((await catRes.json()).categories || [])
if (iconRes.ok) setIcons((await iconRes.json()).icons || [])
if (userRes.ok) setUsers((await userRes.json()).users || []) if (userRes.ok) setUsers((await userRes.json()).users || [])
if (tenantRes.ok) setTenants((await tenantRes.json()).tenants || [])
if (projRes.ok) { if (projRes.ok) {
const projData = await projRes.json() const projData = await projRes.json()
setAllProjects((projData.projects || []).map((p: any) => ({ id: p.id, title: p.title, location: p.location }))) setAllProjects((projData.projects || []).map((p: any) => ({ id: p.id, title: p.title, location: p.location })))
} }
// Load SMTP settings if (isServerAdmin) {
const iconRes = results[3]
const tenantRes = results[4]
if (iconRes.ok) setIcons((await iconRes.json()).icons || [])
if (tenantRes.ok) setTenants((await tenantRes.json()).tenants || [])
}
// Load settings (SERVER_ADMIN only)
if (isServerAdmin) {
try { try {
const smtpRes = await fetch('/api/admin/settings') const smtpRes = await fetch('/api/admin/settings')
if (smtpRes.ok) { if (smtpRes.ok) {
@@ -371,6 +387,7 @@ export default function AdminPage() {
} }
} }
} catch {} } catch {}
}
} catch (error) { } catch (error) {
console.error('Error fetching data:', error) console.error('Error fetching data:', error)
} finally { } finally {
@@ -535,7 +552,8 @@ export default function AdminPage() {
setUploadFiles(null) setUploadFiles(null)
setUploadCategory('') setUploadCategory('')
setUploadIconName('') setUploadIconName('')
fetchData() if (user?.role === 'TENANT_ADMIN') fetchTenantSymbols()
else fetchData()
} catch (error) { } catch (error) {
toast({ title: 'Upload-Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' }) toast({ title: 'Upload-Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
} finally { setIsUploading(false) } } finally { setIsUploading(false) }
@@ -879,7 +897,7 @@ export default function AdminPage() {
placeholder="Symbole suchen..." placeholder="Symbole suchen..."
value={symbolSearch} value={symbolSearch}
onChange={e => setSymbolSearch(e.target.value)} onChange={e => setSymbolSearch(e.target.value)}
className="w-48" className="w-full sm:w-64"
/> />
<Select value={symbolCatFilter} onValueChange={setSymbolCatFilter}> <Select value={symbolCatFilter} onValueChange={setSymbolCatFilter}>
<SelectTrigger className="w-[180px]"> <SelectTrigger className="w-[180px]">
@@ -892,9 +910,13 @@ export default function AdminPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<span className="text-sm text-muted-foreground ml-auto"> <span className="text-sm text-muted-foreground mr-auto">
{tenantSymbols.filter(s => s.isActive).length} aktiv / {tenantSymbols.length} gesamt {tenantSymbols.filter(s => s.isActive).length} aktiv / {tenantSymbols.length} gesamt
</span> </span>
<Button onClick={() => setIsUploadDialogOpen(true)}>
<Upload className="w-4 h-4 mr-2" />
Eigene Symbole hochladen
</Button>
</div> </div>
{/* Bulk category action */} {/* Bulk category action */}
@@ -947,22 +969,22 @@ export default function AdminPage() {
{catName} {catName}
<span className="text-xs text-muted-foreground font-normal">({syms.length})</span> <span className="text-xs text-muted-foreground font-normal">({syms.length})</span>
</h4> </h4>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 gap-2 mb-4"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-3 mb-4">
{syms.map(sym => { {syms.map(sym => {
const selected = selectedSymbolIds.has(sym.id) const selected = selectedSymbolIds.has(sym.id)
return ( return (
<div <div
key={sym.id} key={sym.id}
onClick={() => toggleSelect(sym.id)} onClick={() => toggleSelect(sym.id)}
className={`relative cursor-pointer border-2 rounded-lg p-2 transition-all hover:shadow-sm ${ className={`relative cursor-pointer border-2 rounded-lg p-3 transition-all hover:shadow-sm ${
selected ? 'border-blue-500 bg-blue-50 dark:bg-blue-950/30' : selected ? 'border-blue-500 bg-blue-50 dark:bg-blue-950/30' :
sym.isActive ? 'border-transparent hover:border-border' : 'border-transparent opacity-40' sym.isActive ? 'border-transparent hover:border-border' : 'border-transparent opacity-40'
}`} }`}
> >
<div className="aspect-square flex items-center justify-center mb-1 bg-muted/50 rounded"> <div className="aspect-square flex items-center justify-center mb-1.5 bg-muted/50 rounded">
<img src={`/api/icons/${sym.id}/image`} alt={sym.name} className="w-10 h-10 object-contain" /> <img src={`/api/icons/${sym.id}/image`} alt={sym.name} className="w-16 h-16 object-contain" />
</div> </div>
<p className="text-[10px] text-center truncate" title={sym.name}>{sym.name}</p> <p className="text-xs text-center truncate" title={sym.name}>{sym.name}</p>
{/* Status dot */} {/* Status dot */}
<div className={`absolute top-1.5 right-1.5 w-2 h-2 rounded-full ${sym.isActive ? 'bg-green-500' : 'bg-gray-300'}`} /> <div className={`absolute top-1.5 right-1.5 w-2 h-2 rounded-full ${sym.isActive ? 'bg-green-500' : 'bg-gray-300'}`} />
</div> </div>
@@ -1819,24 +1841,41 @@ export default function AdminPage() {
{user?.role === 'TENANT_ADMIN' && tenant && ( {user?.role === 'TENANT_ADMIN' && tenant && (
<TabsContent value="donate" className="space-y-6"> <TabsContent value="donate" className="space-y-6">
<div className="border rounded-lg p-6"> <div className="border rounded-lg p-6">
<h3 className="font-semibold text-lg mb-4">Lageplan unterstützen</h3> <div className="flex items-start gap-4 mb-6">
<p className="text-muted-foreground mb-4"> <div className="w-12 h-12 rounded-full bg-gradient-to-br from-red-500 to-red-700 flex items-center justify-center shrink-0">
Lageplan ist ein kostenloses Herzensprojekt. Wenn du die Weiterentwicklung unterstützen möchtest, <Heart className="w-6 h-6 text-white" />
kannst du auf unserer Spendenseite einen freiwilligen Beitrag leisten. </div>
<div>
<h3 className="font-semibold text-lg">Lageplan unterstützen</h3>
<p className="text-sm text-muted-foreground mt-1">
Lageplan ist ein kostenloses Open-Source-Projekt entwickelt von einem aktiven Feuerwehrmann
in seiner Freizeit. Ohne Firma, ohne Investoren. Deine Spende hilft, den Betrieb und die
Weiterentwicklung zu finanzieren.
</p> </p>
</div>
</div>
<div className="bg-muted/50 rounded-lg p-4 mb-6">
<p className="text-sm font-medium mb-2">Wohin fliesst deine Spende?</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li className="flex items-center gap-2"><Shield className="w-3.5 h-3.5 text-primary shrink-0" /> Server-Hosting in der Schweiz (monatliche Kosten)</li>
<li className="flex items-center gap-2"><Settings className="w-3.5 h-3.5 text-primary shrink-0" /> Entwicklung neuer Features und Bugfixes</li>
<li className="flex items-center gap-2"><ShieldCheck className="w-3.5 h-3.5 text-primary shrink-0" /> Domain, SSL-Zertifikate und Infrastruktur</li>
</ul>
</div>
<div className="flex flex-wrap gap-3">
<Button asChild> <Button asChild>
<a href="/spenden" target="_blank" rel="noopener noreferrer"> <a href="/spenden" target="_blank" rel="noopener noreferrer">
<Heart className="w-4 h-4 mr-2" /> <Heart className="w-4 h-4 mr-2" />
Zur Spendenseite Jetzt spenden
</a> </a>
</Button> </Button>
<div className="mt-6 pt-4 border-t"> <Button variant="outline" asChild>
<p className="text-sm text-muted-foreground"> <a href="/" target="_blank" rel="noopener noreferrer">
mit von Pepe {' '} Mehr über Lageplan erfahren
<a href="/" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
Über mich & Lageplan
</a> </a>
</p> </Button>
</div> </div>
</div> </div>
<div className="border rounded-lg p-6"> <div className="border rounded-lg p-6">

View File

@@ -10,7 +10,7 @@ const MAX_SIZE = 5 * 1024 * 1024 // 5MB
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const user = await getSession() const user = await getSession()
if (!user || !isAdmin(user.role)) { if (!user || (user.role !== 'SERVER_ADMIN' && user.role !== 'TENANT_ADMIN')) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 }) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
} }
@@ -55,27 +55,25 @@ export async function POST(req: NextRequest) {
// Generate safe filename // Generate safe filename
const ext = file.name.split('.').pop()?.toLowerCase() || 'png' const ext = file.name.split('.').pop()?.toLowerCase() || 'png'
const isTenantAdmin = user.role === 'TENANT_ADMIN'
const prefix = isTenantAdmin ? `tenant-${user.tenantId}/icons` : 'icons'
const safeFileName = `${uuidv4()}.${ext}` const safeFileName = `${uuidv4()}.${ext}`
const fileKey = `icons/${safeFileName}` const fileKey = `${prefix}/${safeFileName}`
// Upload to MinIO // Upload to MinIO
const buffer = Buffer.from(await file.arrayBuffer()) const buffer = Buffer.from(await file.arrayBuffer())
await uploadFile(fileKey, buffer, file.type) await uploadFile(fileKey, buffer, file.type)
// TENANT_ADMIN: icons get tenantId. SERVER_ADMIN: global icons (tenantId=null) // Save to DB
const tenantId = user.role === 'SERVER_ADMIN' ? null : user.tenantId || null
// Create database entry
const icon = await (prisma as any).iconAsset.create({ const icon = await (prisma as any).iconAsset.create({
data: { data: {
name: name.trim(), name: name.trim(),
fileKey,
mimeType: file.type,
categoryId, categoryId,
iconType: iconType as any, iconType: iconType as any,
isSystem: false, fileKey,
isActive: true, mimeType: file.type,
tenantId, isSystem: !isTenantAdmin, // true für Server Admin, false für Tenant Admin
tenantId: isTenantAdmin ? user.tenantId : null,
ownerId: user.id, ownerId: user.id,
}, },
include: { include: {

View File

@@ -32,7 +32,7 @@ export async function GET() {
icons: { icons: {
where: user?.tenantId where: user?.tenantId
? { isActive: true, OR: [{ tenantId: null }, { tenantId: user.tenantId }] } ? { isActive: true, OR: [{ tenantId: null }, { tenantId: user.tenantId }] }
: { isActive: true }, : { isActive: true, tenantId: null },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}, },
}, },

View File

@@ -1087,10 +1087,11 @@ export default function AppPage() {
return return
} }
// Ctrl/Cmd shortcuts (CH keyboard: Z and Y are swapped) // Ctrl/Cmd shortcuts
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
if (e.key === 'z') { e.preventDefault(); handleRedo(); return } if (e.key === 'z' && e.shiftKey) { e.preventDefault(); handleRedo(); return }
if (e.key === 'y') { e.preventDefault(); handleUndo(); return } if (e.key === 'z') { e.preventDefault(); handleUndo(); return }
if (e.key === 'y') { e.preventDefault(); handleRedo(); return }
if (e.key === 's') { e.preventDefault(); handleSaveProject(); return } if (e.key === 's') { e.preventDefault(); handleSaveProject(); return }
return return
} }
@@ -1707,7 +1708,7 @@ export default function AppPage() {
))} ))}
<div className="font-semibold text-muted-foreground col-span-2 mt-3 mb-0.5">Aktionen</div> <div className="font-semibold text-muted-foreground col-span-2 mt-3 mb-0.5">Aktionen</div>
{[ {[
['Ctrl+Y', 'Rückgängig'], ['Ctrl+Z', 'Wiederholen'], ['Ctrl+Z', 'Rückgängig'], ['Ctrl+Y', 'Wiederholen'],
['Ctrl+S', 'Speichern'], ['Del', 'Auswahl löschen'], ['Ctrl+S', 'Speichern'], ['Del', 'Auswahl löschen'],
['Esc', 'Abbrechen'], ['?', 'Diese Hilfe'], ['Esc', 'Abbrechen'], ['?', 'Diese Hilfe'],
].map(([key, label]) => ( ].map(([key, label]) => (