diff --git a/android-app/app/production/release/app-production-release.aab b/android-app/app/production/release/app-production-release.aab new file mode 100644 index 0000000..564c477 Binary files /dev/null and b/android-app/app/production/release/app-production-release.aab differ diff --git a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt index 7368b23..74b063a 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt @@ -2,7 +2,9 @@ package de.harheimertc.ui.navigation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.foundation.layout.Arrangement import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Button import androidx.compose.material3.Surface import androidx.compose.material3.Text 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.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @@ -327,46 +330,141 @@ fun NavGraph( ) } 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) { - 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) { - 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) { - 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) { - 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) { - 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) { - 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) { - 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) { - 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) { - 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) { - 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) { - 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) { val isTeamDetail = route.startsWith("mannschaften/") && route != Destinations.Spielsysteme.route diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsNewsScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsNewsScreens.kt index 6264c27..7e0c4cc 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsNewsScreens.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsNewsScreens.kt @@ -132,15 +132,22 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie items(state.news) { news -> val selected = news.id?.let { selection.contains(it) } ?: false - NewsListItem(news = news, selected = selected, onSelect = { id, sel -> - id?.let { - selection = if (sel) selection + it else selection - it - } - }, onEdit = { openEdit(news) }, onDelete = { news.id?.let { id -> deletingIds = listOf(id) } }) + NewsListItem( + news = news, + canWrite = canWrite, + selected = selected, + 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 - if (selection.isNotEmpty()) { + if (canWrite && selection.isNotEmpty()) { item { Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button(onClick = { viewModel.bulkSetPublic(selection.toList(), true) }) { Text("Als öffentlich markieren") } @@ -255,6 +262,7 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie @Composable private fun NewsListItem( news: NewsDto, + canWrite: Boolean, selected: Boolean = false, onSelect: (String?, Boolean) -> Unit = { _, _ -> }, onEdit: (NewsDto) -> Unit, @@ -264,7 +272,11 @@ private fun NewsListItem( Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Column(modifier = Modifier.weight(1f)) { 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)) if (news.isPublic) { Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) { @@ -297,9 +309,11 @@ private fun NewsListItem( Text("Aktualisiert: ${news.updated}") } } - Row { - TextButton(onClick = { onEdit(news) }) { Text("Bearbeiten") } - TextButton(onClick = { news.id?.let { onDelete(it) } }) { Text("Löschen") } + if (canWrite) { + Row { + TextButton(onClick = { onEdit(news) }) { Text("Bearbeiten") } + TextButton(onClick = { news.id?.let { onDelete(it) } }) { Text("Löschen") } + } } } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt index 0a51469..ddb677c 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt @@ -872,7 +872,9 @@ fun CmsNewsletterScreen( if (!state.loading && state.newsletters.isEmpty()) item { EmptyCard("Keine Newsletter gefunden.") } items(state.newsletters.size) { index -> val item = state.newsletters[index] - NewsletterCard(item, + NewsletterCard( + item, + canWrite = canWrite, onEdit = { nl -> editingNewsletter = nl nlTitle = nl.title @@ -890,7 +892,9 @@ fun CmsNewsletterScreen( if (!state.loading && state.newsletterGroups.isEmpty()) item { EmptyCard("Keine Gruppen gefunden.") } items(state.newsletterGroups.size) { index -> val group = state.newsletterGroups[index] - NewsletterGroupCard(group, + NewsletterGroupCard( + group, + canWrite = canWrite, onEdit = { g -> editingGroup = g grpName = g.name @@ -1564,29 +1568,44 @@ private fun ContactRequestCard(request: ContactRequestDto, viewModel: CmsViewMod } @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 } }) { InfoRow("Status", newsletter.status ?: if (newsletter.sentAt != null) "versendet" else "Entwurf") InfoRow("Erstellt", newsletter.createdAt ?: "-") InfoRow("Versendet", newsletter.sentAt ?: "-") - Row { - TextButton(onClick = { onEdit(newsletter) }) { Text("Bearbeiten") } - TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") } - if (newsletter.status != "sent") { - TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onSend(it) } }) { Text("Versenden") } + if (canWrite) { + Row { + TextButton(onClick = { onEdit(newsletter) }) { Text("Bearbeiten") } + TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") } + if (newsletter.status != "sent") { + TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onSend(it) } }) { Text("Versenden") } + } } } } } @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 }) { InfoRow("Beschreibung", group.description.ifBlank { "-" }) InfoRow("Abonnenten", group.subscribers.size.toString()) - Row { - TextButton(onClick = { onEdit(group) }) { Text("Bearbeiten") } - TextButton(onClick = { group.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") } + if (canWrite) { + Row { + TextButton(onClick = { onEdit(group) }) { Text("Bearbeiten") } + TextButton(onClick = { group.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") } + } } } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt index 0557bcd..05db259 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt @@ -117,20 +117,22 @@ fun HomeScreen( ) } } - item { - HomeCustomizationSection( - sections = state.homepageSections, - spielplanSeasons = state.spielplanSeasons, - spielplanTeamsBySeason = state.spielplanTeamsBySeason, - editEnabled = editHomeSections, - onToggleEdit = { editHomeSections = !editHomeSections }, - onMoveUp = viewModel::moveSectionUp, - onMoveDown = viewModel::moveSectionDown, - onEnabledChange = viewModel::setSectionEnabled, - onAddSpielplanWidget = viewModel::addSpielplanTeamWidget, - onUpdateSpielplanWidget = viewModel::updateSpielplanTeamWidget, - onReset = viewModel::resetSections, - ) + if (navigationState.canAccessFullCms) { + item { + HomeCustomizationSection( + sections = state.homepageSections, + spielplanSeasons = state.spielplanSeasons, + spielplanTeamsBySeason = state.spielplanTeamsBySeason, + editEnabled = editHomeSections, + onToggleEdit = { editHomeSections = !editHomeSections }, + onMoveUp = viewModel::moveSectionUp, + onMoveDown = viewModel::moveSectionDown, + onEnabledChange = viewModel::setSectionEnabled, + onAddSpielplanWidget = viewModel::addSpielplanTeamWidget, + onUpdateSpielplanWidget = viewModel::updateSpielplanTeamWidget, + onReset = viewModel::resetSections, + ) + } } if (state.error) { item { diff --git a/android-app/gradle.properties b/android-app/gradle.properties index b63cad4..abab8f6 100644 --- a/android-app/gradle.properties +++ b/android-app/gradle.properties @@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/ PRODUCTION_API_BASE_URL=https://harheimertc.de/ # Android app versioning for Play Store uploads -ANDROID_VERSION_CODE=26 -ANDROID_VERSION_NAME=0.9.21 +ANDROID_VERSION_CODE=28 +ANDROID_VERSION_NAME=0.9.23 # Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping. RELEASE_MINIFY_ENABLED=false diff --git a/package.json b/package.json index d287eed..62f2f94 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "harheimertc-website", - "version": "1.8.1", + "version": "1.8.2", "description": "Moderne Webseite für den Harheimer Tischtennis Club", "private": true, "type": "module", diff --git a/server/api/membership/applications.get.js b/server/api/membership/applications.get.js index 1c024b6..b9aa9c2 100644 --- a/server/api/membership/applications.get.js +++ b/server/api/membership/applications.get.js @@ -1,15 +1,11 @@ import fs from 'fs/promises' import path from 'path' +import { requireUserWithAnyRole } from '../../utils/auth.js' import { decryptObject } from '../../utils/encryption.js' export default defineEventHandler(async (event) => { try { - // Nur Vorstand oder Admin darf Mitgliedschaftsantraege lesen - 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' }) - } + await requireUserWithAnyRole(event, 'admin', 'vorstand') const config = useRuntimeConfig() const encryptionKey = config.encryptionKey || 'local_development_encryption_key_change_in_production' diff --git a/server/api/membership/update-status.put.js b/server/api/membership/update-status.put.js index 2647290..e047f5c 100644 --- a/server/api/membership/update-status.put.js +++ b/server/api/membership/update-status.put.js @@ -1,10 +1,13 @@ import fs from 'fs/promises' import path from 'path' +import { requireUserWithAnyRole } from '../../utils/auth.js' import { decryptObject } from '../../utils/encryption.js' import { saveMember } from '../../utils/members.js' export default defineEventHandler(async (event) => { try { + await requireUserWithAnyRole(event, 'admin', 'vorstand') + const { id, status, notes } = await readBody(event) if (!id || !status) { diff --git a/server/utils/auth.js b/server/utils/auth.js index c5fec68..c2e6c39 100644 --- a/server/utils/auth.js +++ b/server/utils/auth.js @@ -399,6 +399,42 @@ export async function getUserFromToken(token) { 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 export async function createSession(userId, token) { const sessions = await readSessions()