dev #43

Merged
admin merged 2 commits from dev into main 2026-06-23 23:50:22 +02:00
11 changed files with 1074 additions and 961 deletions

View File

@@ -2,7 +2,9 @@ package de.harheimertc.ui.navigation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Button
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
@@ -10,6 +12,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -327,46 +330,141 @@ fun NavGraph(
) )
} }
composable(Destinations.CmsStartseite.route) { composable(Destinations.CmsStartseite.route) {
de.harheimertc.ui.screens.cms.CmsStartseiteScreen(navController, !persistentNavigation) RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsStartseiteScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.CmsInhalte.route) { composable(Destinations.CmsInhalte.route) {
de.harheimertc.ui.screens.cms.CmsInhalteScreen(navController, !persistentNavigation) RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsInhalteScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.CmsVereinsmeisterschaften.route) { composable(Destinations.CmsVereinsmeisterschaften.route) {
de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation) RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.CmsNews.route) { composable(Destinations.CmsNews.route) {
de.harheimertc.ui.screens.cms.CmsNewsScreen(navController, !persistentNavigation) RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsNewsScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.CmsSportbetrieb.route) { composable(Destinations.CmsSportbetrieb.route) {
de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation) RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.CmsMitgliederverwaltung.route) { composable(Destinations.CmsMitgliederverwaltung.route) {
de.harheimertc.ui.screens.cms.CmsMitgliederverwaltungScreen(navController, !persistentNavigation) RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsMitgliederverwaltungScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.CmsNewsletter.route) { composable(Destinations.CmsNewsletter.route) {
de.harheimertc.ui.screens.cms.CmsNewsletterScreen(navController, !persistentNavigation) RequireCmsAccess(
allowed = navigationState.canAccessNewsletter,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsNewsletterScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.CmsContactRequests.route) { composable(Destinations.CmsContactRequests.route) {
de.harheimertc.ui.screens.cms.CmsContactRequestsScreen(navController, !persistentNavigation) RequireCmsAccess(
allowed = navigationState.canAccessContactRequests,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsContactRequestsScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.CmsEinstellungen.route) { composable(Destinations.CmsEinstellungen.route) {
de.harheimertc.ui.screens.cms.CmsEinstellungenScreen(navController, !persistentNavigation) RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsEinstellungenScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.CmsBenutzer.route) { composable(Destinations.CmsBenutzer.route) {
de.harheimertc.ui.screens.cms.CmsBenutzerScreen(navController, !persistentNavigation) RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsBenutzerScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.CmsPasswordResetDiagnostics.route) { composable(Destinations.CmsPasswordResetDiagnostics.route) {
de.harheimertc.ui.screens.cms.CmsPasswordResetDiagnosticsScreen(navController, !persistentNavigation) RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsPasswordResetDiagnosticsScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.Cms.route) { composable(Destinations.Cms.route) {
de.harheimertc.ui.screens.cms.CmsDashboardScreen(navController, !persistentNavigation) RequireCmsAccess(
allowed = navigationState.canAccessCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsDashboardScreen(navController, !persistentNavigation)
}
} }
} }
} }
} }
} }
@Composable
private fun RequireCmsAccess(
allowed: Boolean,
navController: NavHostController,
content: @Composable () -> Unit,
) {
if (allowed) {
content()
return
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Kein Zugriff auf diesen Bereich.",
style = MaterialTheme.typography.headlineSmall,
color = Color(0xFF7F1D1D),
)
Text(
text = "Bitte mit einem berechtigten Konto anmelden.",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF7C2D12),
modifier = Modifier.padding(top = 8.dp, bottom = 16.dp),
)
Button(onClick = { navController.navigateTopLevel(Destinations.Login.route) }) {
Text("Zum Login")
}
}
}
private fun NavHostController.navigateTopLevel(route: String) { private fun NavHostController.navigateTopLevel(route: String) {
val isTeamDetail = route.startsWith("mannschaften/") && val isTeamDetail = route.startsWith("mannschaften/") &&
route != Destinations.Spielsysteme.route route != Destinations.Spielsysteme.route

View File

@@ -132,15 +132,22 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
items(state.news) { news -> items(state.news) { news ->
val selected = news.id?.let { selection.contains(it) } ?: false val selected = news.id?.let { selection.contains(it) } ?: false
NewsListItem(news = news, selected = selected, onSelect = { id, sel -> NewsListItem(
id?.let { news = news,
selection = if (sel) selection + it else selection - it canWrite = canWrite,
} selected = selected,
}, onEdit = { openEdit(news) }, onDelete = { news.id?.let { id -> deletingIds = listOf(id) } }) onSelect = { id, sel ->
id?.let {
selection = if (sel) selection + it else selection - it
}
},
onEdit = { openEdit(news) },
onDelete = { news.id?.let { id -> deletingIds = listOf(id) } },
)
} }
// bulk action bar // bulk action bar
if (selection.isNotEmpty()) { if (canWrite && selection.isNotEmpty()) {
item { item {
Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { viewModel.bulkSetPublic(selection.toList(), true) }) { Text("Als öffentlich markieren") } Button(onClick = { viewModel.bulkSetPublic(selection.toList(), true) }) { Text("Als öffentlich markieren") }
@@ -255,6 +262,7 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
@Composable @Composable
private fun NewsListItem( private fun NewsListItem(
news: NewsDto, news: NewsDto,
canWrite: Boolean,
selected: Boolean = false, selected: Boolean = false,
onSelect: (String?, Boolean) -> Unit = { _, _ -> }, onSelect: (String?, Boolean) -> Unit = { _, _ -> },
onEdit: (NewsDto) -> Unit, onEdit: (NewsDto) -> Unit,
@@ -264,7 +272,11 @@ private fun NewsListItem(
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
Checkbox(checked = selected, onCheckedChange = { onSelect(news.id, it) }) Checkbox(
checked = selected,
onCheckedChange = { onSelect(news.id, it) },
enabled = canWrite,
)
Text(news.title.ifBlank { "(Ohne Titel)" }, modifier = Modifier.padding(start = 8.dp)) Text(news.title.ifBlank { "(Ohne Titel)" }, modifier = Modifier.padding(start = 8.dp))
if (news.isPublic) { if (news.isPublic) {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) { Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
@@ -297,9 +309,11 @@ private fun NewsListItem(
Text("Aktualisiert: ${news.updated}") Text("Aktualisiert: ${news.updated}")
} }
} }
Row { if (canWrite) {
TextButton(onClick = { onEdit(news) }) { Text("Bearbeiten") } Row {
TextButton(onClick = { news.id?.let { onDelete(it) } }) { Text("Löschen") } TextButton(onClick = { onEdit(news) }) { Text("Bearbeiten") }
TextButton(onClick = { news.id?.let { onDelete(it) } }) { Text("Löschen") }
}
} }
} }
} }

View File

@@ -872,7 +872,9 @@ fun CmsNewsletterScreen(
if (!state.loading && state.newsletters.isEmpty()) item { EmptyCard("Keine Newsletter gefunden.") } if (!state.loading && state.newsletters.isEmpty()) item { EmptyCard("Keine Newsletter gefunden.") }
items(state.newsletters.size) { index -> items(state.newsletters.size) { index ->
val item = state.newsletters[index] val item = state.newsletters[index]
NewsletterCard(item, NewsletterCard(
item,
canWrite = canWrite,
onEdit = { nl -> onEdit = { nl ->
editingNewsletter = nl editingNewsletter = nl
nlTitle = nl.title nlTitle = nl.title
@@ -890,7 +892,9 @@ fun CmsNewsletterScreen(
if (!state.loading && state.newsletterGroups.isEmpty()) item { EmptyCard("Keine Gruppen gefunden.") } if (!state.loading && state.newsletterGroups.isEmpty()) item { EmptyCard("Keine Gruppen gefunden.") }
items(state.newsletterGroups.size) { index -> items(state.newsletterGroups.size) { index ->
val group = state.newsletterGroups[index] val group = state.newsletterGroups[index]
NewsletterGroupCard(group, NewsletterGroupCard(
group,
canWrite = canWrite,
onEdit = { g -> onEdit = { g ->
editingGroup = g editingGroup = g
grpName = g.name grpName = g.name
@@ -1564,29 +1568,44 @@ private fun ContactRequestCard(request: ContactRequestDto, viewModel: CmsViewMod
} }
@Composable @Composable
private fun NewsletterCard(newsletter: NewsletterDto, onEdit: (NewsletterDto) -> Unit = {}, onDelete: (String) -> Unit = {}, onSend: (String) -> Unit = {}) { private fun NewsletterCard(
newsletter: NewsletterDto,
canWrite: Boolean,
onEdit: (NewsletterDto) -> Unit = {},
onDelete: (String) -> Unit = {},
onSend: (String) -> Unit = {},
) {
DataCard(newsletter.subject.ifBlank { newsletter.title.ifBlank { newsletter.id } }) { DataCard(newsletter.subject.ifBlank { newsletter.title.ifBlank { newsletter.id } }) {
InfoRow("Status", newsletter.status ?: if (newsletter.sentAt != null) "versendet" else "Entwurf") InfoRow("Status", newsletter.status ?: if (newsletter.sentAt != null) "versendet" else "Entwurf")
InfoRow("Erstellt", newsletter.createdAt ?: "-") InfoRow("Erstellt", newsletter.createdAt ?: "-")
InfoRow("Versendet", newsletter.sentAt ?: "-") InfoRow("Versendet", newsletter.sentAt ?: "-")
Row { if (canWrite) {
TextButton(onClick = { onEdit(newsletter) }) { Text("Bearbeiten") } Row {
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") } TextButton(onClick = { onEdit(newsletter) }) { Text("Bearbeiten") }
if (newsletter.status != "sent") { TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onSend(it) } }) { Text("Versenden") } if (newsletter.status != "sent") {
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onSend(it) } }) { Text("Versenden") }
}
} }
} }
} }
} }
@Composable @Composable
private fun NewsletterGroupCard(group: NewsletterGroupDto, onEdit: (NewsletterGroupDto) -> Unit = {}, onDelete: (String) -> Unit = {}) { private fun NewsletterGroupCard(
group: NewsletterGroupDto,
canWrite: Boolean,
onEdit: (NewsletterGroupDto) -> Unit = {},
onDelete: (String) -> Unit = {},
) {
DataCard(group.name.ifBlank { group.id }) { DataCard(group.name.ifBlank { group.id }) {
InfoRow("Beschreibung", group.description.ifBlank { "-" }) InfoRow("Beschreibung", group.description.ifBlank { "-" })
InfoRow("Abonnenten", group.subscribers.size.toString()) InfoRow("Abonnenten", group.subscribers.size.toString())
Row { if (canWrite) {
TextButton(onClick = { onEdit(group) }) { Text("Bearbeiten") } Row {
TextButton(onClick = { group.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") } TextButton(onClick = { onEdit(group) }) { Text("Bearbeiten") }
TextButton(onClick = { group.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
}
} }
} }
} }

View File

@@ -117,20 +117,22 @@ fun HomeScreen(
) )
} }
} }
item { if (navigationState.canAccessFullCms) {
HomeCustomizationSection( item {
sections = state.homepageSections, HomeCustomizationSection(
spielplanSeasons = state.spielplanSeasons, sections = state.homepageSections,
spielplanTeamsBySeason = state.spielplanTeamsBySeason, spielplanSeasons = state.spielplanSeasons,
editEnabled = editHomeSections, spielplanTeamsBySeason = state.spielplanTeamsBySeason,
onToggleEdit = { editHomeSections = !editHomeSections }, editEnabled = editHomeSections,
onMoveUp = viewModel::moveSectionUp, onToggleEdit = { editHomeSections = !editHomeSections },
onMoveDown = viewModel::moveSectionDown, onMoveUp = viewModel::moveSectionUp,
onEnabledChange = viewModel::setSectionEnabled, onMoveDown = viewModel::moveSectionDown,
onAddSpielplanWidget = viewModel::addSpielplanTeamWidget, onEnabledChange = viewModel::setSectionEnabled,
onUpdateSpielplanWidget = viewModel::updateSpielplanTeamWidget, onAddSpielplanWidget = viewModel::addSpielplanTeamWidget,
onReset = viewModel::resetSections, onUpdateSpielplanWidget = viewModel::updateSpielplanTeamWidget,
) onReset = viewModel::resetSections,
)
}
} }
if (state.error) { if (state.error) {
item { item {

View File

@@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
PRODUCTION_API_BASE_URL=https://harheimertc.de/ PRODUCTION_API_BASE_URL=https://harheimertc.de/
# Android app versioning for Play Store uploads # Android app versioning for Play Store uploads
ANDROID_VERSION_CODE=26 ANDROID_VERSION_CODE=28
ANDROID_VERSION_NAME=0.9.21 ANDROID_VERSION_NAME=0.9.23
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping. # Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
RELEASE_MINIFY_ENABLED=false RELEASE_MINIFY_ENABLED=false

1751
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "harheimertc-website", "name": "harheimertc-website",
"version": "1.8.1", "version": "1.8.2",
"description": "Moderne Webseite für den Harheimer Tischtennis Club", "description": "Moderne Webseite für den Harheimer Tischtennis Club",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -39,7 +39,7 @@
"dompurify": "^3.3.1", "dompurify": "^3.3.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"multer": "^2.0.2", "multer": "^2.0.2",
"nodemailer": "^8.0.5", "nodemailer": "^9.0.1",
"nuxt": "^4.1.3", "nuxt": "^4.1.3",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdf-parse": "^2.4.5", "pdf-parse": "^2.4.5",

View File

@@ -1,15 +1,11 @@
import fs from 'fs/promises' import fs from 'fs/promises'
import path from 'path' import path from 'path'
import { requireUserWithAnyRole } from '../../utils/auth.js'
import { decryptObject } from '../../utils/encryption.js' import { decryptObject } from '../../utils/encryption.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
// Nur Vorstand oder Admin darf Mitgliedschaftsantraege lesen await requireUserWithAnyRole(event, 'admin', 'vorstand')
const token = getCookie(event, 'auth_token')
const currentUser = token ? await getUserFromToken(token) : null
if (!currentUser || !hasAnyRole(currentUser, 'admin', 'vorstand')) {
throw createError({ statusCode: 403, statusMessage: 'Zugriff verweigert' })
}
const config = useRuntimeConfig() const config = useRuntimeConfig()
const encryptionKey = config.encryptionKey || 'local_development_encryption_key_change_in_production' const encryptionKey = config.encryptionKey || 'local_development_encryption_key_change_in_production'

View File

@@ -1,10 +1,13 @@
import fs from 'fs/promises' import fs from 'fs/promises'
import path from 'path' import path from 'path'
import { requireUserWithAnyRole } from '../../utils/auth.js'
import { decryptObject } from '../../utils/encryption.js' import { decryptObject } from '../../utils/encryption.js'
import { saveMember } from '../../utils/members.js' import { saveMember } from '../../utils/members.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
await requireUserWithAnyRole(event, 'admin', 'vorstand')
const { id, status, notes } = await readBody(event) const { id, status, notes } = await readBody(event)
if (!id || !status) { if (!id || !status) {

View File

@@ -399,6 +399,42 @@ export async function getUserFromToken(token) {
return user return user
} }
export function getTokenFromEvent(event) {
return getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
}
export async function requireAuthenticatedUser(event) {
const token = getTokenFromEvent(event)
if (!token) {
throw createError({
statusCode: 401,
statusMessage: 'Nicht authentifiziert'
})
}
const user = await getUserFromToken(token)
if (!user) {
throw createError({
statusCode: 401,
statusMessage: 'Ungültige Sitzung'
})
}
return user
}
export async function requireUserWithAnyRole(event, ...roles) {
const user = await requireAuthenticatedUser(event)
if (!hasAnyRole(user, ...roles)) {
throw createError({
statusCode: 403,
statusMessage: 'Zugriff verweigert'
})
}
return user
}
// Create session // Create session
export async function createSession(userId, token) { export async function createSession(userId, token) {
const sessions = await readSessions() const sessions = await readSessions()