Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ddeb7b377 | ||
|
|
f480905bb9 | ||
|
|
18398e559c |
@@ -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'",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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,47 +330,64 @@ 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) {
|
||||||
try {
|
const iconRes = results[3]
|
||||||
const smtpRes = await fetch('/api/admin/settings')
|
const tenantRes = results[4]
|
||||||
if (smtpRes.ok) {
|
if (iconRes.ok) setIcons((await iconRes.json()).icons || [])
|
||||||
const smtpData = await smtpRes.json()
|
if (tenantRes.ok) setTenants((await tenantRes.json()).tenants || [])
|
||||||
if (smtpData.smtp) {
|
}
|
||||||
setSmtpHost(smtpData.smtp.host || '')
|
|
||||||
setSmtpPort(String(smtpData.smtp.port || 587))
|
// Load settings (SERVER_ADMIN only)
|
||||||
setSmtpSecure(smtpData.smtp.secure || false)
|
if (isServerAdmin) {
|
||||||
setSmtpUser(smtpData.smtp.user || '')
|
try {
|
||||||
setSmtpPass(smtpData.smtp.pass || '')
|
const smtpRes = await fetch('/api/admin/settings')
|
||||||
setSmtpFromName(smtpData.smtp.fromName || 'Lageplan')
|
if (smtpRes.ok) {
|
||||||
setSmtpFromEmail(smtpData.smtp.fromEmail || '')
|
const smtpData = await smtpRes.json()
|
||||||
|
if (smtpData.smtp) {
|
||||||
|
setSmtpHost(smtpData.smtp.host || '')
|
||||||
|
setSmtpPort(String(smtpData.smtp.port || 587))
|
||||||
|
setSmtpSecure(smtpData.smtp.secure || false)
|
||||||
|
setSmtpUser(smtpData.smtp.user || '')
|
||||||
|
setSmtpPass(smtpData.smtp.pass || '')
|
||||||
|
setSmtpFromName(smtpData.smtp.fromName || 'Lageplan')
|
||||||
|
setSmtpFromEmail(smtpData.smtp.fromEmail || '')
|
||||||
|
}
|
||||||
|
if (smtpData.contactEmail) setContactEmail(smtpData.contactEmail)
|
||||||
|
if (smtpData.notifyRegistrationEmail) setNotifyRegistrationEmail(smtpData.notifyRegistrationEmail)
|
||||||
|
if (smtpData.demoProjectId) setDemoProjectId(smtpData.demoProjectId)
|
||||||
|
if (smtpData.defaultSymbolScale) setDefaultSymbolScale(smtpData.defaultSymbolScale)
|
||||||
|
if (smtpData.stripe) {
|
||||||
|
setStripePublicKey(smtpData.stripe.publicKey || '')
|
||||||
|
setStripeSecretKey(smtpData.stripe.secretKey || '')
|
||||||
|
setStripeWebhookSecret(smtpData.stripe.webhookSecret || '')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (smtpData.contactEmail) setContactEmail(smtpData.contactEmail)
|
} catch {}
|
||||||
if (smtpData.notifyRegistrationEmail) setNotifyRegistrationEmail(smtpData.notifyRegistrationEmail)
|
}
|
||||||
if (smtpData.demoProjectId) setDemoProjectId(smtpData.demoProjectId)
|
|
||||||
if (smtpData.defaultSymbolScale) setDefaultSymbolScale(smtpData.defaultSymbolScale)
|
|
||||||
if (smtpData.stripe) {
|
|
||||||
setStripePublicKey(smtpData.stripe.publicKey || '')
|
|
||||||
setStripeSecretKey(smtpData.stripe.secretKey || '')
|
|
||||||
setStripeWebhookSecret(smtpData.stripe.webhookSecret || '')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} 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>
|
||||||
@@ -1033,7 +1055,7 @@ export default function AdminPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -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>
|
||||||
</p>
|
<div>
|
||||||
<Button asChild>
|
<h3 className="font-semibold text-lg">Lageplan unterstützen</h3>
|
||||||
<a href="/spenden" target="_blank" rel="noopener noreferrer">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
<Heart className="w-4 h-4 mr-2" />
|
Lageplan ist ein kostenloses Open-Source-Projekt — entwickelt von einem aktiven Feuerwehrmann
|
||||||
Zur Spendenseite
|
in seiner Freizeit. Ohne Firma, ohne Investoren. Deine Spende hilft, den Betrieb und die
|
||||||
</a>
|
Weiterentwicklung zu finanzieren.
|
||||||
</Button>
|
</p>
|
||||||
<div className="mt-6 pt-4 border-t">
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
</div>
|
||||||
mit ♥ von Pepe —{' '}
|
|
||||||
<a href="/" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
<div className="bg-muted/50 rounded-lg p-4 mb-6">
|
||||||
Über mich & Lageplan
|
<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>
|
||||||
|
<a href="/spenden" target="_blank" rel="noopener noreferrer">
|
||||||
|
<Heart className="w-4 h-4 mr-2" />
|
||||||
|
Jetzt spenden
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</Button>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<a href="/" target="_blank" rel="noopener noreferrer">
|
||||||
|
Mehr über Lageplan erfahren
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="border rounded-lg p-6">
|
<div className="border rounded-lg p-6">
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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]) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user