Merge pull request 'dev' (#41) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 3m0s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped

Reviewed-on: #41
This commit was merged in pull request #41.
This commit is contained in:
2026-06-10 16:49:07 +02:00
116 changed files with 3594 additions and 710 deletions

View File

@@ -146,7 +146,7 @@ jobs:
-o BatchMode=yes \ -o BatchMode=yes \
-p "${{ vars.PROD_PORT }}" \ -p "${{ vars.PROD_PORT }}" \
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \ "${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
"bash -lc 'cd /var/www/harheimertc && git fetch origin main && git checkout -B main origin/main && git reset --hard origin/main && ./deploy-production.sh'" "bash -lc 'cd /var/www/harheimertc && git reset --hard HEAD && git fetch origin main && git checkout -B main origin/main && git reset --hard origin/main && ./deploy-production.sh'"
deploy-test: deploy-test:
needs: analyze needs: analyze
@@ -177,4 +177,4 @@ jobs:
-o BatchMode=yes \ -o BatchMode=yes \
-p "${{ vars.PROD_PORT }}" \ -p "${{ vars.PROD_PORT }}" \
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \ "${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
"bash -lc 'cd /var/www/harheimertc.test && git fetch origin dev && git checkout -B dev origin/dev && git reset --hard origin/dev && ./deploy-test.sh'" "bash -lc 'cd /var/www/harheimertc.test && git reset --hard HEAD && git fetch origin dev && git checkout -B dev origin/dev && git reset --hard origin/dev && ./deploy-test.sh'"

3
.gitleaksignore Normal file
View File

@@ -0,0 +1,3 @@
google-services.json:gcp-api-key:18
google-services.json:gcp-api-key:37
google-services.json:gcp-api-key:56

View File

@@ -7,12 +7,17 @@ plugins {
id("com.google.dagger.hilt.android") id("com.google.dagger.hilt.android")
} }
if (file("google-services.json").exists()) {
apply(plugin = "com.google.gms.google-services")
}
val localApiBaseUrl = providers.gradleProperty("LOCAL_API_BASE_URL") val localApiBaseUrl = providers.gradleProperty("LOCAL_API_BASE_URL")
.orElse("https://harheimertc.tsschulz.de/") .orElse("https://harheimertc.tsschulz.de/")
.get() .get()
val productionApiBaseUrl = providers.gradleProperty("PRODUCTION_API_BASE_URL") val productionApiBaseUrl = providers.gradleProperty("PRODUCTION_API_BASE_URL")
.orElse("https://harheimertc.de/") .orElse("https://harheimertc.de/")
.get() .get()
val expectedProductionApiBaseUrl = "https://harheimertc.de/"
val sentryDsn = providers.gradleProperty("SENTRY_DSN") val sentryDsn = providers.gradleProperty("SENTRY_DSN")
.orElse("") .orElse("")
.get() .get()
@@ -61,6 +66,16 @@ val ensureReleaseSigning = tasks.register("ensureReleaseSigning") {
} }
} }
val ensureProductionApiBaseUrl = tasks.register("ensureProductionApiBaseUrl") {
doFirst {
if (productionApiBaseUrl != expectedProductionApiBaseUrl) {
throw GradleException(
"Production Play Store builds must use $expectedProductionApiBaseUrl, but PRODUCTION_API_BASE_URL is $productionApiBaseUrl."
)
}
}
}
android { android {
namespace = "de.harheimertc" namespace = "de.harheimertc"
compileSdk = 35 compileSdk = 35
@@ -163,6 +178,7 @@ val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
group = "distribution" group = "distribution"
description = "Builds production release artifacts and collects AAB, mapping, and native symbols for Play Console upload." description = "Builds production release artifacts and collects AAB, mapping, and native symbols for Play Console upload."
dependsOn(ensureReleaseSigning) dependsOn(ensureReleaseSigning)
dependsOn(ensureProductionApiBaseUrl)
dependsOn(":app:bundleProductionRelease") dependsOn(":app:bundleProductionRelease")
dependsOn(packageNativeDebugSymbolsForProductionRelease) dependsOn(packageNativeDebugSymbolsForProductionRelease)
@@ -193,6 +209,7 @@ tasks.matching {
it.name in setOf("bundleProductionRelease", "assembleProductionRelease") it.name in setOf("bundleProductionRelease", "assembleProductionRelease")
}.configureEach { }.configureEach {
dependsOn(ensureReleaseSigning) dependsOn(ensureReleaseSigning)
dependsOn(ensureProductionApiBaseUrl)
} }
kotlin { kotlin {
@@ -240,6 +257,9 @@ dependencies {
// Crash reporting // Crash reporting
implementation("io.sentry:sentry-android:8.42.0") implementation("io.sentry:sentry-android:8.42.0")
// Push notifications
implementation("com.google.firebase:firebase-messaging:25.0.2")
// Room // Room
implementation("androidx.room:room-runtime:2.6.1") implementation("androidx.room:room-runtime:2.6.1")
ksp("androidx.room:room-compiler:2.6.1") ksp("androidx.room:room-compiler:2.6.1")
@@ -249,6 +269,7 @@ dependencies {
implementation("androidx.work:work-runtime-ktx:2.8.1") implementation("androidx.work:work-runtime-ktx:2.8.1")
implementation("androidx.datastore:datastore-preferences:1.0.0") implementation("androidx.datastore:datastore-preferences:1.0.0")
implementation("androidx.security:security-crypto:1.1.0-alpha06") implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")
// Testing (skeleton) // Testing (skeleton)
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")

View File

@@ -0,0 +1 @@
../../google-services.json

View File

@@ -2,6 +2,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:name=".HarheimerApplication" android:name=".HarheimerApplication"
@@ -10,12 +11,20 @@
android:theme="@style/Theme.HarheimerTC" android:theme="@style/Theme.HarheimerTC"
android:usesCleartextTraffic="${usesCleartextTraffic}"> android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity android:name="de.harheimertc.MainActivity" <activity android:name="de.harheimertc.MainActivity"
android:exported="true"> android:exported="true"
android:launchMode="singleTop">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".notifications.HarheimerMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.files" android:authorities="${applicationId}.files"

View File

@@ -6,6 +6,7 @@ import coil.ImageLoaderFactory
import coil.disk.DiskCache import coil.disk.DiskCache
import coil.memory.MemoryCache import coil.memory.MemoryCache
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import de.harheimertc.notifications.HarheimerNotifications
import io.sentry.Sentry import io.sentry.Sentry
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import javax.inject.Inject import javax.inject.Inject
@@ -19,6 +20,7 @@ class HarheimerApplication : Application(), ImageLoaderFactory {
override fun onCreate() { override fun onCreate() {
Log.d("HILT", "HarheimerApplication.onCreate called") Log.d("HILT", "HarheimerApplication.onCreate called")
super.onCreate() super.onCreate()
HarheimerNotifications.createChannels(this)
if (BuildConfig.SENTRY_DSN.isNotBlank()) { if (BuildConfig.SENTRY_DSN.isNotBlank()) {
Sentry.init { options -> Sentry.init { options ->
options.dsn = BuildConfig.SENTRY_DSN options.dsn = BuildConfig.SENTRY_DSN

View File

@@ -1,13 +1,20 @@
package de.harheimertc package de.harheimertc
import android.Manifest
import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import de.harheimertc.ui.navigation.NavGraph import de.harheimertc.ui.navigation.NavGraph
import de.harheimertc.ui.navigation.Destinations import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.notifications.HarheimerNotifications
import de.harheimertc.ui.theme.HarheimerTheme import de.harheimertc.ui.theme.HarheimerTheme
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import de.harheimertc.ui.navigation.NavigationViewModel import de.harheimertc.ui.navigation.NavigationViewModel
@@ -17,22 +24,67 @@ import androidx.compose.ui.platform.LocalContext
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val notificationRoute = mutableStateOf<String?>(null)
private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
Log.i("NOTIFICATIONS", "POST_NOTIFICATIONS granted=$granted")
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
requestNotificationPermissionIfNeeded()
notificationRoute.value = extractNotificationRoute(intent)
setContent { setContent {
App() App(
notificationRoute = notificationRoute.value,
onNotificationRouteConsumed = { notificationRoute.value = null },
)
} }
} }
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
notificationRoute.value = extractNotificationRoute(intent)
}
private fun extractNotificationRoute(intent: Intent?): String? =
intent?.getStringExtra(EXTRA_NOTIFICATION_ROUTE)?.takeIf { it.isNotBlank() }
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !HarheimerNotifications.hasNotificationPermission(this)) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
companion object {
const val EXTRA_NOTIFICATION_TYPE = "de.harheimertc.extra.NOTIFICATION_TYPE"
const val EXTRA_NOTIFICATION_ROUTE = "de.harheimertc.extra.NOTIFICATION_ROUTE"
const val EXTRA_NEWS_ID = "de.harheimertc.extra.NEWS_ID"
}
} }
@Composable @Composable
fun App() { fun App(
notificationRoute: String? = null,
onNotificationRouteConsumed: () -> Unit = {},
) {
HarheimerTheme { HarheimerTheme {
val navController = rememberNavController() val navController = rememberNavController()
val ctx = LocalContext.current val ctx = LocalContext.current
val activity = ctx as? ComponentActivity val activity = ctx as? ComponentActivity
Log.i("HILT_FACTORY", "defaultViewModelProviderFactory=${activity?.defaultViewModelProviderFactory?.javaClass?.name}") Log.i("HILT_FACTORY", "defaultViewModelProviderFactory=${activity?.defaultViewModelProviderFactory?.javaClass?.name}")
val navigationViewModel: NavigationViewModel = hiltViewModel() val navigationViewModel: NavigationViewModel = hiltViewModel()
LaunchedEffect(notificationRoute) {
val route = notificationRoute?.takeIf { it.isNotBlank() } ?: return@LaunchedEffect
navController.navigate(route) {
launchSingleTop = true
popUpTo(Destinations.Home.route)
}
onNotificationRouteConsumed()
}
NavGraph(navController = navController, navigationViewModelParam = navigationViewModel) NavGraph(navController = navController, navigationViewModelParam = navigationViewModel)
} }
} }

View File

@@ -37,6 +37,12 @@ data class SpielplanResponse(
val seasons: List<SeasonDto> = emptyList(), val seasons: List<SeasonDto> = emptyList(),
) )
data class SeasonDto(val slug: String = "", val label: String = "") data class SeasonDto(val slug: String = "", val label: String = "")
data class MannschaftenSeasonsResponse(
val success: Boolean = false,
val seasons: List<String> = emptyList(),
val currentSeason: String = "",
val defaultSeason: String = "",
)
data class SpielDto( data class SpielDto(
@param:Json(name = "Termin") val termin: String = "", @param:Json(name = "Termin") val termin: String = "",
@param:Json(name = "HeimMannschaft") val heimMannschaft: String = "", @param:Json(name = "HeimMannschaft") val heimMannschaft: String = "",
@@ -251,6 +257,30 @@ data class ProfileUpdateRequest(
val currentPassword: String? = null, val currentPassword: String? = null,
val newPassword: String? = null, val newPassword: String? = null,
) )
data class NotificationSettingsDto(
val newNews: Boolean = false,
val newEvents: Boolean = false,
val eventsToday: Boolean = false,
val eventsTomorrow: Boolean = false,
val ownTeamMatches: Boolean = false,
val allTeamMatches: Boolean = false,
val birthdays: Boolean = false,
val newContactRequest: Boolean = false,
val newUserRegistration: Boolean = false,
val selectedTeamSlugs: List<String> = emptyList(),
val selectedTeamSeason: String? = null,
val notificationTime: String = "09:00",
)
data class NotificationSettingsResponse(
val success: Boolean = false,
val message: String? = null,
val settings: NotificationSettingsDto = NotificationSettingsDto(),
)
data class PushTokenRequest(
val token: String,
val platform: String = "android",
val appVersion: String? = null,
)
data class BirthdayDto( data class BirthdayDto(
val name: String = "", val name: String = "",
val dayMonth: String = "", val dayMonth: String = "",
@@ -584,6 +614,9 @@ interface ApiService {
@GET("/api/mannschaften") @GET("/api/mannschaften")
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody> suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
@GET("/api/mannschaften/seasons")
suspend fun mannschaftenSeasons(): Response<MannschaftenSeasonsResponse>
@GET("/api/config") @GET("/api/config")
suspend fun config(): Response<ConfigResponse> suspend fun config(): Response<ConfigResponse>
@@ -651,6 +684,15 @@ interface ApiService {
@retrofit2.http.PUT("/api/profile") @retrofit2.http.PUT("/api/profile")
suspend fun updateProfile(@Body request: ProfileUpdateRequest): Response<ProfileResponse> suspend fun updateProfile(@Body request: ProfileUpdateRequest): Response<ProfileResponse>
@GET("/api/profile/notifications")
suspend fun notificationSettings(): Response<NotificationSettingsResponse>
@retrofit2.http.PUT("/api/profile/notifications")
suspend fun updateNotificationSettings(@Body request: NotificationSettingsDto): Response<NotificationSettingsResponse>
@POST("/api/profile/push-token")
suspend fun registerPushToken(@Body request: PushTokenRequest): Response<AuthMessageResponse>
@GET("/api/birthdays") @GET("/api/birthdays")
suspend fun birthdays(): Response<BirthdaysResponse> suspend fun birthdays(): Response<BirthdaysResponse>

View File

@@ -0,0 +1,49 @@
package de.harheimertc.data
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ConnectivityMonitor @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _online = MutableStateFlow(hasInternetAccess())
val online: StateFlow<Boolean> = _online.asStateFlow()
init {
scope.launch { poll() }
}
private suspend fun poll() {
while (currentCoroutineContext().isActive) {
val current = hasInternetAccess()
if (_online.value != current) {
_online.value = current
}
delay(10_000L)
}
}
private fun hasInternetAccess(): Boolean {
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return false
val network = manager.activeNetwork ?: return false
val capabilities = manager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
}

View File

@@ -1,11 +1,13 @@
package de.harheimertc.data package de.harheimertc.data
import android.content.Context import android.content.Context
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.Types import com.squareup.moshi.Types
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.security.GeneralSecurityException
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -14,6 +16,7 @@ class SecureOfflineCache @Inject constructor(
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val moshi: Moshi, private val moshi: Moshi,
) { ) {
private val tag = "SecureOfflineCache"
private companion object { private companion object {
const val KEY_BIRTHDAYS = "birthdays" const val KEY_BIRTHDAYS = "birthdays"
const val KEY_QTTR_VALUES = "qttr_values" const val KEY_QTTR_VALUES = "qttr_values"
@@ -29,6 +32,10 @@ class SecureOfflineCache @Inject constructor(
} }
private val preferences by lazy { private val preferences by lazy {
buildEncryptedPreferences()
}
private fun buildEncryptedPreferences() = try {
val masterKey = MasterKey.Builder(context) val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build() .build()
@@ -39,6 +46,28 @@ class SecureOfflineCache @Inject constructor(
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
) )
} catch (error: GeneralSecurityException) {
recoverEncryptedPreferences(error)
} catch (error: RuntimeException) {
recoverEncryptedPreferences(error)
}
private fun recoverEncryptedPreferences(error: Throwable) = try {
Log.w(tag, "EncryptedSharedPreferences defekt, Offline-Cache wird neu angelegt", error)
context.deleteSharedPreferences("harheimertc_offline_cache")
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"harheimertc_offline_cache",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
} catch (retryError: Throwable) {
Log.e(tag, "Offline-Cache konnte nicht wiederhergestellt werden", retryError)
throw retryError
} }
fun putBirthdays(response: BirthdaysResponse) = put(KEY_BIRTHDAYS, response, BirthdaysResponse::class.java) fun putBirthdays(response: BirthdaysResponse) = put(KEY_BIRTHDAYS, response, BirthdaysResponse::class.java)

View File

@@ -0,0 +1,43 @@
package de.harheimertc.notifications
import android.util.Log
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import dagger.hilt.android.AndroidEntryPoint
import de.harheimertc.repositories.PushTokenRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class HarheimerMessagingService : FirebaseMessagingService() {
@Inject
lateinit var pushTokenRepository: PushTokenRepository
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onNewToken(token: String) {
super.onNewToken(token)
serviceScope.launch {
pushTokenRepository.registerToken(token)
}
}
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
val title = message.notification?.title
?: message.data["title"]
?: "Harheimer TC"
val body = message.notification?.body
?: message.data["body"]
?: message.data["message"]
?: return
val notificationId = message.data["notificationId"]?.toIntOrNull()
?: message.messageId?.hashCode()
?: System.currentTimeMillis().toInt()
val shown = HarheimerNotifications.showBasicNotification(this, notificationId, title, body, message.data)
Log.d("HarheimerMessaging", "Push message received type=${message.data["type"]}, shown=$shown")
}
}

View File

@@ -0,0 +1,78 @@
package de.harheimertc.notifications
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import de.harheimertc.MainActivity
import de.harheimertc.R
import de.harheimertc.ui.navigation.Destinations
object HarheimerNotifications {
const val DEFAULT_CHANNEL_ID = "harheimer_tc_updates"
fun createChannels(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val channel = NotificationChannel(
DEFAULT_CHANNEL_ID,
"Harheimer TC",
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Benachrichtigungen des Harheimer TC"
}
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
fun hasNotificationPermission(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
fun showBasicNotification(
context: Context,
notificationId: Int,
title: String,
message: String,
data: Map<String, String> = emptyMap(),
): Boolean {
if (!hasNotificationPermission(context)) return false
val notification = NotificationCompat.Builder(context, DEFAULT_CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(createContentIntent(context, notificationId, data))
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(context).notify(notificationId, notification)
return true
}
private fun createContentIntent(context: Context, notificationId: Int, payload: Map<String, String>): PendingIntent {
val route = destinationRoute(payload)
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra(MainActivity.EXTRA_NOTIFICATION_TYPE, payload["type"])
putExtra(MainActivity.EXTRA_NOTIFICATION_ROUTE, route)
payload["newsId"]?.let { putExtra(MainActivity.EXTRA_NEWS_ID, it) }
}
return PendingIntent.getActivity(
context,
notificationId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
private fun destinationRoute(data: Map<String, String>): String = when (data["type"]) {
"news" -> Destinations.MemberNews.route
else -> Destinations.Home.route
}
}

View File

@@ -1,10 +1,12 @@
package de.harheimertc.repositories package de.harheimertc.repositories
import android.content.Context import android.content.Context
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.harheimertc.security.DeviceKeyManager import de.harheimertc.security.DeviceKeyManager
import java.security.GeneralSecurityException
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -13,10 +15,15 @@ class AuthRepositoryImpl @Inject constructor(
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val deviceKeyManager: DeviceKeyManager, private val deviceKeyManager: DeviceKeyManager,
) : AuthRepository { ) : AuthRepository {
private val tag = "AuthRepository"
private val tokenKey = "auth_token" private val tokenKey = "auth_token"
private val refreshTokenKey = "auth_refresh_token" private val refreshTokenKey = "auth_refresh_token"
private val sessionIdKey = "auth_session_id" private val sessionIdKey = "auth_session_id"
private val preferences by lazy { private val preferences by lazy {
buildEncryptedPreferences()
}
private fun buildEncryptedPreferences() = try {
val masterKey = MasterKey.Builder(context) val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build() .build()
@@ -27,6 +34,28 @@ class AuthRepositoryImpl @Inject constructor(
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
) )
} catch (error: GeneralSecurityException) {
recoverEncryptedPreferences(error)
} catch (error: RuntimeException) {
recoverEncryptedPreferences(error)
}
private fun recoverEncryptedPreferences(error: Throwable) = try {
Log.w(tag, "EncryptedSharedPreferences defekt, Session wird neu angelegt", error)
context.deleteSharedPreferences("harheimertc_auth")
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"harheimertc_auth",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
} catch (retryError: Throwable) {
Log.e(tag, "EncryptedSharedPreferences konnte nicht wiederhergestellt werden", retryError)
throw retryError
} }
override fun getToken(): String? = preferences.getString(tokenKey, null) override fun getToken(): String? = preferences.getString(tokenKey, null)

View File

@@ -24,27 +24,27 @@ class GalleryRepository @Inject constructor(
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
) { ) {
suspend fun hasPublicImages(): Result<Boolean> = runCatching { suspend fun hasPublicImages(): Result<Boolean> = runCatching {
val response = api.galerieList(page = 1, perPage = 1) retryOnNetworkFailure {
if (!response.isSuccessful) error("HTTP ${response.code()}") val response = api.galerieList(page = 1, perPage = 1)
response.body()?.images.orEmpty().isNotEmpty() if (!response.isSuccessful) error("HTTP ${response.code()}")
response.body()?.images.orEmpty().isNotEmpty()
}
} }
suspend fun fetchImages(page: Int = 1, perPage: Int = 60): Result<GalleryPage> { suspend fun fetchImages(page: Int = 1, perPage: Int = 60): Result<GalleryPage> {
return try { return runCatching {
val resp = api.galerieList(page = page, perPage = perPage) retryOnNetworkFailure {
if (resp.isSuccessful) { val resp = api.galerieList(page = page, perPage = perPage)
val body = resp.body() if (resp.isSuccessful) {
Result.success( val body = resp.body()
GalleryPage( GalleryPage(
images = body?.images.orEmpty().map { it.toGalleryImage() }, images = body?.images.orEmpty().map { it.toGalleryImage() },
pagination = body?.pagination ?: GalleryPaginationDto(), pagination = body?.pagination ?: GalleryPaginationDto(),
), )
) } else {
} else { error("HTTP ${resp.code()}")
Result.failure(Exception("HTTP ${resp.code()}")) }
} }
} catch (e: Exception) {
Result.failure(e)
} }
} }

View File

@@ -1,12 +1,14 @@
package de.harheimertc.repositories package de.harheimertc.repositories
import android.content.Context import android.content.Context
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.Types import com.squareup.moshi.Types
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.harheimertc.data.HomepageSectionDto import de.harheimertc.data.HomepageSectionDto
import java.security.GeneralSecurityException
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -15,10 +17,15 @@ class HomeLayoutPreferences @Inject constructor(
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val moshi: Moshi, private val moshi: Moshi,
) { ) {
private val tag = "HomeLayoutPreferences"
private val sectionListType = Types.newParameterizedType(List::class.java, HomepageSectionDto::class.java) private val sectionListType = Types.newParameterizedType(List::class.java, HomepageSectionDto::class.java)
private val sectionListAdapter = moshi.adapter<List<HomepageSectionDto>>(sectionListType) private val sectionListAdapter = moshi.adapter<List<HomepageSectionDto>>(sectionListType)
private val preferences by lazy { private val preferences by lazy {
buildEncryptedPreferences()
}
private fun buildEncryptedPreferences() = try {
val masterKey = MasterKey.Builder(context) val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build() .build()
@@ -29,6 +36,28 @@ class HomeLayoutPreferences @Inject constructor(
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
) )
} catch (error: GeneralSecurityException) {
recoverEncryptedPreferences(error)
} catch (error: RuntimeException) {
recoverEncryptedPreferences(error)
}
private fun recoverEncryptedPreferences(error: Throwable) = try {
Log.w(tag, "EncryptedSharedPreferences defekt, Home-Layout wird neu angelegt", error)
context.deleteSharedPreferences("harheimertc_home_layout")
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"harheimertc_home_layout",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
} catch (retryError: Throwable) {
Log.e(tag, "Home-Layout-Preferences konnten nicht wiederhergestellt werden", retryError)
throw retryError
} }
fun getSections(): List<HomepageSectionDto>? { fun getSections(): List<HomepageSectionDto>? {

View File

@@ -31,19 +31,21 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
val diagnostics = mutableListOf<String>() val diagnostics = mutableListOf<String>()
val termine = runCatching { val termine = runCatching {
val response = api.termine() retryOnNetworkFailure {
if (!response.isSuccessful) { val response = api.termine()
val errorBody = response.errorBody()?.string().orEmpty() if (!response.isSuccessful) {
diagnostics += buildDiagnostic( val errorBody = response.errorBody()?.string().orEmpty()
endpoint = "GET /api/termine", diagnostics += buildDiagnostic(
requestPayload = "none", endpoint = "GET /api/termine",
httpCode = response.code(), requestPayload = "none",
responseBody = errorBody, httpCode = response.code(),
throwable = null, responseBody = errorBody,
) throwable = null,
error("Termine konnten nicht geladen werden (HTTP ${response.code()}).") )
error("Termine konnten nicht geladen werden (HTTP ${response.code()}).")
}
response.body()?.termine.orEmpty()
} }
response.body()?.termine.orEmpty()
}.onFailure { error -> }.onFailure { error ->
captureLoadIssue("fetchHomeData.termine", error) captureLoadIssue("fetchHomeData.termine", error)
if (diagnostics.none { it.contains("GET /api/termine") }) { if (diagnostics.none { it.contains("GET /api/termine") }) {
@@ -58,19 +60,21 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
}.getOrDefault(emptyList()) }.getOrDefault(emptyList())
val spielplanResponse = runCatching { val spielplanResponse = runCatching {
val response = api.spielplan() retryOnNetworkFailure {
if (!response.isSuccessful) { val response = api.spielplan()
val errorBody = response.errorBody()?.string().orEmpty() if (!response.isSuccessful) {
diagnostics += buildDiagnostic( val errorBody = response.errorBody()?.string().orEmpty()
endpoint = "GET /api/spielplan", diagnostics += buildDiagnostic(
requestPayload = "none", endpoint = "GET /api/spielplan",
httpCode = response.code(), requestPayload = "none",
responseBody = errorBody, httpCode = response.code(),
throwable = null, responseBody = errorBody,
) throwable = null,
error("Spielplan konnte nicht geladen werden (HTTP ${response.code()}).") )
error("Spielplan konnte nicht geladen werden (HTTP ${response.code()}).")
}
response.body()
} }
response.body()
}.onFailure { error -> }.onFailure { error ->
captureLoadIssue("fetchHomeData.spielplan", error) captureLoadIssue("fetchHomeData.spielplan", error)
if (diagnostics.none { it.contains("GET /api/spielplan") }) { if (diagnostics.none { it.contains("GET /api/spielplan") }) {
@@ -86,19 +90,21 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
val spiele = spielplanResponse?.data.orEmpty() val spiele = spielplanResponse?.data.orEmpty()
val news = runCatching { val news = runCatching {
val response = api.publicNews() retryOnNetworkFailure {
if (!response.isSuccessful) { val response = api.publicNews()
val errorBody = response.errorBody()?.string().orEmpty() if (!response.isSuccessful) {
diagnostics += buildDiagnostic( val errorBody = response.errorBody()?.string().orEmpty()
endpoint = "GET /api/news-public", diagnostics += buildDiagnostic(
requestPayload = "none", endpoint = "GET /api/news-public",
httpCode = response.code(), requestPayload = "none",
responseBody = errorBody, httpCode = response.code(),
throwable = null, responseBody = errorBody,
) throwable = null,
error("News konnten nicht geladen werden (HTTP ${response.code()}).") )
error("News konnten nicht geladen werden (HTTP ${response.code()}).")
}
response.body()?.news.orEmpty()
} }
response.body()?.news.orEmpty()
}.onFailure { error -> }.onFailure { error ->
captureLoadIssue("fetchHomeData.news", error) captureLoadIssue("fetchHomeData.news", error)
if (diagnostics.none { it.contains("GET /api/news-public") }) { if (diagnostics.none { it.contains("GET /api/news-public") }) {
@@ -113,19 +119,21 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
}.getOrDefault(emptyList()) }.getOrDefault(emptyList())
val homepageSections = runCatching { val homepageSections = runCatching {
val response = api.config() retryOnNetworkFailure {
if (!response.isSuccessful) { val response = api.config()
val errorBody = response.errorBody()?.string().orEmpty() if (!response.isSuccessful) {
diagnostics += buildDiagnostic( val errorBody = response.errorBody()?.string().orEmpty()
endpoint = "GET /api/config", diagnostics += buildDiagnostic(
requestPayload = "none", endpoint = "GET /api/config",
httpCode = response.code(), requestPayload = "none",
responseBody = errorBody, httpCode = response.code(),
throwable = null, responseBody = errorBody,
) throwable = null,
error("Konfiguration konnte nicht geladen werden (HTTP ${response.code()}).") )
error("Konfiguration konnte nicht geladen werden (HTTP ${response.code()}).")
}
response.body()?.homepage?.sections.orEmpty()
} }
response.body()?.homepage?.sections.orEmpty()
}.onFailure { error -> }.onFailure { error ->
captureLoadIssue("fetchHomeData.config", error) captureLoadIssue("fetchHomeData.config", error)
if (diagnostics.none { it.contains("GET /api/config") }) { if (diagnostics.none { it.contains("GET /api/config") }) {
@@ -140,20 +148,22 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
}.getOrDefault(emptyList()) }.getOrDefault(emptyList())
val heroImageUrl = runCatching { val heroImageUrl = runCatching {
val response = api.heroImages() retryOnNetworkFailure {
if (!response.isSuccessful) { val response = api.heroImages()
val errorBody = response.errorBody()?.string().orEmpty() if (!response.isSuccessful) {
diagnostics += buildDiagnostic( val errorBody = response.errorBody()?.string().orEmpty()
endpoint = "GET /api/hero-images", diagnostics += buildDiagnostic(
requestPayload = "none", endpoint = "GET /api/hero-images",
httpCode = response.code(), requestPayload = "none",
responseBody = errorBody, httpCode = response.code(),
throwable = null, responseBody = errorBody,
) throwable = null,
error("Hero-Bilder konnten nicht geladen werden (HTTP ${response.code()}).") )
error("Hero-Bilder konnten nicht geladen werden (HTTP ${response.code()}).")
}
val variants = response.body()?.variants.orEmpty()
pickRandomHeroImage(variants)
} }
val variants = response.body()?.variants.orEmpty()
pickRandomHeroImage(variants)
}.onFailure { error -> }.onFailure { error ->
captureLoadIssue("fetchHomeData.heroImages", error) captureLoadIssue("fetchHomeData.heroImages", error)
if (diagnostics.none { it.contains("GET /api/hero-images") }) { if (diagnostics.none { it.contains("GET /api/hero-images") }) {
@@ -187,9 +197,11 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
} }
suspend fun fetchSpielplanForSeason(season: String): Result<SpielplanResponse> = runCatching { suspend fun fetchSpielplanForSeason(season: String): Result<SpielplanResponse> = runCatching {
val response = api.spielplan(season) retryOnNetworkFailure {
if (!response.isSuccessful) error("HTTP ${response.code()}") val response = api.spielplan(season)
response.body() ?: error("Leere Antwort") if (!response.isSuccessful) error("HTTP ${response.code()}")
response.body() ?: error("Leere Antwort")
}
}.onFailure { error -> }.onFailure { error ->
Sentry.withScope { scope -> Sentry.withScope { scope ->
scope.setTag("repository", "HomeRepository") scope.setTag("repository", "HomeRepository")

View File

@@ -23,7 +23,7 @@ class LoginRepository @Inject constructor(
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching { suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching {
val endpoint = "api/auth/login" val endpoint = "api/auth/login"
val requestPreview = "{email=\"${maskEmail(email)}\", client=\"android\", deviceName=\"Harheimer TC Android-App\"}" val requestPreview = "{email=\"${maskEmail(email)}\", client=\"android\", deviceName=\"Harheimer TC Android-App\"}"
val response = api.login(LoginRequest(email.trim(), password)) val response = retryOnNetworkFailure { api.login(LoginRequest(email.trim(), password)) }
if (!response.isSuccessful) { if (!response.isSuccessful) {
val body = response.errorBody()?.string().orEmpty() val body = response.errorBody()?.string().orEmpty()
val serverMessage = extractServerMessage(body) val serverMessage = extractServerMessage(body)
@@ -71,11 +71,11 @@ class LoginRepository @Inject constructor(
return@runCatching AuthStatusResponse() return@runCatching AuthStatusResponse()
} }
var response = api.authStatus() var response = retryOnNetworkFailure { api.authStatus() }
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.") if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
var status = response.body() ?: AuthStatusResponse() var status = response.body() ?: AuthStatusResponse()
if (!status.isLoggedIn && authRepository.getRefreshToken() != null && sessionRefresher.refreshAccessToken()) { if (!status.isLoggedIn && authRepository.getRefreshToken() != null && sessionRefresher.refreshAccessToken()) {
response = api.authStatus() response = retryOnNetworkFailure { api.authStatus() }
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.") if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
status = response.body() ?: AuthStatusResponse() status = response.body() ?: AuthStatusResponse()
} }
@@ -93,15 +93,19 @@ class LoginRepository @Inject constructor(
} }
suspend fun resetPassword(email: String): Result<AuthMessageResponse> = runCatching { suspend fun resetPassword(email: String): Result<AuthMessageResponse> = runCatching {
val response = api.resetPassword(ResetPasswordRequest(email.trim())) retryOnNetworkFailure {
if (!response.isSuccessful) error("Anfrage konnte nicht gesendet werden.") val response = api.resetPassword(ResetPasswordRequest(email.trim()))
response.body() ?: error("Leere Antwort") if (!response.isSuccessful) error("Anfrage konnte nicht gesendet werden.")
response.body() ?: error("Leere Antwort")
}
} }
suspend fun register(request: RegistrationRequest): Result<AuthMessageResponse> = runCatching { suspend fun register(request: RegistrationRequest): Result<AuthMessageResponse> = runCatching {
val response = api.register(request) retryOnNetworkFailure {
if (!response.isSuccessful) error("Registrierung fehlgeschlagen.") val response = api.register(request)
response.body() ?: error("Leere Antwort") if (!response.isSuccessful) error("Registrierung fehlgeschlagen.")
response.body() ?: error("Leere Antwort")
}
} }
private fun extractServerMessage(raw: String): String? { private fun extractServerMessage(raw: String): String? {

View File

@@ -1,6 +1,8 @@
package de.harheimertc.repositories package de.harheimertc.repositories
import de.harheimertc.data.ApiService import de.harheimertc.data.ApiService
import de.harheimertc.data.MannschaftenSeasonsResponse
import de.harheimertc.data.SeasonDto
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -24,9 +26,19 @@ data class Mannschaft(
@Singleton @Singleton
class MannschaftenRepository @Inject constructor(private val api: ApiService) { class MannschaftenRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchMannschaften(season: String? = null): Result<List<Mannschaft>> = runCatching { suspend fun fetchMannschaften(season: String? = null): Result<List<Mannschaft>> = runCatching {
val response = api.mannschaften(season) retryOnNetworkFailure {
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.") val response = api.mannschaften(season)
parseCsv(response.body()?.string().orEmpty()) if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
parseCsv(response.body()?.string().orEmpty())
}
}
suspend fun fetchSeasons(): Result<MannschaftenSeasonsResponse> = runCatching {
retryOnNetworkFailure {
val response = api.mannschaftenSeasons()
if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.")
response.body() ?: error("Saisons konnten nicht geladen werden.")
}
} }
private fun parseCsv(csv: String): List<Mannschaft> = csv.lineSequence() private fun parseCsv(csv: String): List<Mannschaft> = csv.lineSequence()

View File

@@ -0,0 +1,33 @@
package de.harheimertc.repositories
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.net.ssl.SSLException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
internal suspend fun <T> retryOnNetworkFailure(
retryDelayMillis: Long = 10_000L,
block: suspend () -> T,
): T {
while (true) {
try {
return block()
} catch (error: Throwable) {
if (error is CancellationException) throw error
if (!error.isRetryableNetworkError()) throw error
delay(retryDelayMillis)
}
}
}
private fun Throwable.isRetryableNetworkError(): Boolean = when (this) {
is UnknownHostException,
is ConnectException,
is NoRouteToHostException,
is SocketTimeoutException,
is SSLException -> true
else -> false
}

View File

@@ -8,9 +8,11 @@ import javax.inject.Inject
class NewsletterRepository @Inject constructor(private val api: ApiService) { class NewsletterRepository @Inject constructor(private val api: ApiService) {
suspend fun groups(): Result<NewsletterGroupsResponse> = runCatching { suspend fun groups(): Result<NewsletterGroupsResponse> = runCatching {
val response = api.publicNewsletterGroups() retryOnNetworkFailure {
if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.") val response = api.publicNewsletterGroups()
response.body() ?: error("Leere Antwort vom Server.") if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort vom Server.")
}
} }
suspend fun subscribe(groupId: String, email: String, name: String?): Result<AuthMessageResponse> = runCatching { suspend fun subscribe(groupId: String, email: String, name: String?): Result<AuthMessageResponse> = runCatching {
@@ -26,8 +28,10 @@ class NewsletterRepository @Inject constructor(private val api: ApiService) {
} }
suspend fun confirm(token: String): Result<AuthMessageResponse> = runCatching { suspend fun confirm(token: String): Result<AuthMessageResponse> = runCatching {
val response = api.confirmNewsletter(token) retryOnNetworkFailure {
if (!response.isSuccessful) error("Newsletter-Bestätigung fehlgeschlagen.") val response = api.confirmNewsletter(token)
response.body() ?: AuthMessageResponse(success = true, message = "Newsletter-Anmeldung bestätigt.") if (!response.isSuccessful) error("Newsletter-Bestätigung fehlgeschlagen.")
response.body() ?: AuthMessageResponse(success = true, message = "Newsletter-Anmeldung bestätigt.")
}
} }
} }

View File

@@ -0,0 +1,135 @@
package de.harheimertc.repositories
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import de.harheimertc.data.ApiService
import de.harheimertc.data.NotificationSettingsDto
import javax.inject.Inject
import javax.inject.Singleton
private const val DEFAULT_NOTIFICATION_TIME = "09:00"
data class NotificationPreferences(
val newNews: Boolean = false,
val newEvents: Boolean = false,
val eventsToday: Boolean = false,
val eventsTomorrow: Boolean = false,
val ownTeamMatches: Boolean = false,
val allTeamMatches: Boolean = false,
val birthdays: Boolean = false,
val newContactRequest: Boolean = false,
val newUserRegistration: Boolean = false,
val selectedTeamSlugs: Set<String> = emptySet(),
val selectedTeamSeason: String? = null,
val notificationTime: String = DEFAULT_NOTIFICATION_TIME,
)
@Singleton
class NotificationPreferencesRepository @Inject constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiService,
) {
private val preferences by lazy {
context.getSharedPreferences("harheimertc_notification_preferences", Context.MODE_PRIVATE)
}
fun loadLocal(): NotificationPreferences = NotificationPreferences(
newNews = preferences.getBoolean(KEY_NEW_NEWS, false),
newEvents = preferences.getBoolean(KEY_NEW_EVENTS, false),
eventsToday = preferences.getBoolean(KEY_EVENTS_TODAY, false),
eventsTomorrow = preferences.getBoolean(KEY_EVENTS_TOMORROW, false),
ownTeamMatches = preferences.getBoolean(KEY_OWN_TEAM_MATCHES, false),
allTeamMatches = preferences.getBoolean(KEY_ALL_TEAM_MATCHES, false),
birthdays = preferences.getBoolean(KEY_BIRTHDAYS, false),
newContactRequest = preferences.getBoolean(KEY_NEW_CONTACT_REQUEST, false),
newUserRegistration = preferences.getBoolean(KEY_NEW_USER_REGISTRATION, false),
selectedTeamSlugs = preferences.getStringSet(KEY_SELECTED_TEAM_SLUGS, emptySet()).orEmpty(),
selectedTeamSeason = preferences.getString(KEY_SELECTED_TEAM_SEASON, null)?.takeIf { it.isNotBlank() },
notificationTime = preferences.getString(KEY_NOTIFICATION_TIME, DEFAULT_NOTIFICATION_TIME) ?: DEFAULT_NOTIFICATION_TIME,
)
suspend fun loadRemote(): Result<NotificationPreferences> = runCatching {
retryOnNetworkFailure {
val response = api.notificationSettings()
if (!response.isSuccessful) error("Benachrichtigungseinstellungen konnten nicht geladen werden.")
val settings = response.body()?.settings?.toPreferences() ?: error("Leere Antwort")
saveLocal(settings)
settings
}
}
fun saveLocal(settings: NotificationPreferences) {
preferences.edit()
.putBoolean(KEY_NEW_NEWS, settings.newNews)
.putBoolean(KEY_NEW_EVENTS, settings.newEvents)
.putBoolean(KEY_EVENTS_TODAY, settings.eventsToday)
.putBoolean(KEY_EVENTS_TOMORROW, settings.eventsTomorrow)
.putBoolean(KEY_OWN_TEAM_MATCHES, settings.ownTeamMatches)
.putBoolean(KEY_ALL_TEAM_MATCHES, settings.allTeamMatches)
.putBoolean(KEY_BIRTHDAYS, settings.birthdays)
.putBoolean(KEY_NEW_CONTACT_REQUEST, settings.newContactRequest)
.putBoolean(KEY_NEW_USER_REGISTRATION, settings.newUserRegistration)
.putStringSet(KEY_SELECTED_TEAM_SLUGS, settings.selectedTeamSlugs)
.putString(KEY_SELECTED_TEAM_SEASON, settings.selectedTeamSeason)
.putString(KEY_NOTIFICATION_TIME, settings.notificationTime)
.apply()
}
suspend fun saveRemote(settings: NotificationPreferences): Result<NotificationPreferences> {
saveLocal(settings)
return runCatching {
retryOnNetworkFailure {
val response = api.updateNotificationSettings(settings.toDto())
if (!response.isSuccessful) error("Benachrichtigungseinstellungen konnten nicht gespeichert werden.")
val saved = response.body()?.settings?.toPreferences() ?: error("Leere Antwort")
saveLocal(saved)
saved
}
}
}
private companion object {
const val KEY_NEW_NEWS = "new_news"
const val KEY_NEW_EVENTS = "new_events"
const val KEY_EVENTS_TODAY = "events_today"
const val KEY_EVENTS_TOMORROW = "events_tomorrow"
const val KEY_OWN_TEAM_MATCHES = "own_team_matches"
const val KEY_ALL_TEAM_MATCHES = "all_team_matches"
const val KEY_BIRTHDAYS = "birthdays"
const val KEY_NEW_CONTACT_REQUEST = "new_contact_request"
const val KEY_NEW_USER_REGISTRATION = "new_user_registration"
const val KEY_SELECTED_TEAM_SLUGS = "selected_team_slugs"
const val KEY_SELECTED_TEAM_SEASON = "selected_team_season"
const val KEY_NOTIFICATION_TIME = "notification_time"
}
}
private fun NotificationSettingsDto.toPreferences(): NotificationPreferences = NotificationPreferences(
newNews = newNews,
newEvents = newEvents,
eventsToday = eventsToday,
eventsTomorrow = eventsTomorrow,
ownTeamMatches = ownTeamMatches,
allTeamMatches = allTeamMatches,
birthdays = birthdays,
newContactRequest = newContactRequest,
newUserRegistration = newUserRegistration,
selectedTeamSlugs = selectedTeamSlugs.toSet(),
selectedTeamSeason = selectedTeamSeason,
notificationTime = notificationTime,
)
private fun NotificationPreferences.toDto(): NotificationSettingsDto = NotificationSettingsDto(
newNews = newNews,
newEvents = newEvents,
eventsToday = eventsToday,
eventsTomorrow = eventsTomorrow,
ownTeamMatches = ownTeamMatches,
allTeamMatches = allTeamMatches,
birthdays = birthdays,
newContactRequest = newContactRequest,
newUserRegistration = newUserRegistration,
selectedTeamSlugs = selectedTeamSlugs.toList(),
selectedTeamSeason = selectedTeamSeason,
notificationTime = notificationTime,
)

View File

@@ -28,68 +28,74 @@ class PasskeyRepository @Inject constructor(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
) { ) {
suspend fun login(context: Context, email: String?): Result<LoginResponse> = runCatching { suspend fun login(context: Context, email: String?): Result<LoginResponse> = runCatching {
val optionsResponse = api.passkeyAuthenticationOptions( retryOnNetworkFailure {
PasskeyAuthenticationOptionsRequest(email = email?.trim()?.takeIf(String::isNotBlank)), val optionsResponse = api.passkeyAuthenticationOptions(
) PasskeyAuthenticationOptionsRequest(email = email?.trim()?.takeIf(String::isNotBlank)),
if (!optionsResponse.isSuccessful) error("Passkey-Anmeldung konnte nicht gestartet werden.") )
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options") if (!optionsResponse.isSuccessful) error("Passkey-Anmeldung konnte nicht gestartet werden.")
?: error("Der Server hat keine Passkey-Optionen geliefert.") val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
?: error("Der Server hat keine Passkey-Optionen geliefert.")
val credentialManager = CredentialManager.create(context) val credentialManager = CredentialManager.create(context)
val credentialResponse = credentialManager.getCredential( val credentialResponse = credentialManager.getCredential(
context = context, context = context,
request = GetCredentialRequest( request = GetCredentialRequest(
credentialOptions = listOf(GetPublicKeyCredentialOption(optionsJson)), credentialOptions = listOf(GetPublicKeyCredentialOption(optionsJson)),
), ),
) )
val credential = credentialResponse.credential as? PublicKeyCredential val credential = credentialResponse.credential as? PublicKeyCredential
?: error("Der ausgewählte Zugang ist kein Passkey.") ?: error("Der ausgewählte Zugang ist kein Passkey.")
val response = api.passkeyLogin( val response = api.passkeyLogin(
JSONObject() JSONObject()
.put("credential", JSONObject(credential.authenticationResponseJson)) .put("credential", JSONObject(credential.authenticationResponseJson))
.put("client", "android") .put("client", "android")
.put("deviceName", "Harheimer TC Android-App") .put("deviceName", "Harheimer TC Android-App")
.toString() .toString()
.toRequestBody(MediaTypes.json), .toRequestBody(MediaTypes.json),
) )
if (!response.isSuccessful) error("Passkey-Anmeldung fehlgeschlagen.") if (!response.isSuccessful) error("Passkey-Anmeldung fehlgeschlagen.")
val body = response.body() ?: error("Leere Antwort") val body = response.body() ?: error("Leere Antwort")
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank) val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
?: error("Der Server hat kein Zugriffstoken geliefert.") ?: error("Der Server hat kein Zugriffstoken geliefert.")
authRepository.setSession(token, body.refreshToken, body.sessionId) authRepository.setSession(token, body.refreshToken, body.sessionId)
body body
}
}.recoverCredentialCancellation("Passkey-Anmeldung abgebrochen.") }.recoverCredentialCancellation("Passkey-Anmeldung abgebrochen.")
suspend fun list(): Result<PasskeysResponse> = runCatching { suspend fun list(): Result<PasskeysResponse> = runCatching {
val response = api.passkeys() retryOnNetworkFailure {
if (!response.isSuccessful) error("Passkeys konnten nicht geladen werden.") val response = api.passkeys()
response.body() ?: error("Leere Antwort") if (!response.isSuccessful) error("Passkeys konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort")
}
} }
suspend fun add(context: Context, name: String = "Android-App"): Result<AuthMessageResponse> = runCatching { suspend fun add(context: Context, name: String = "Android-App"): Result<AuthMessageResponse> = runCatching {
val optionsResponse = api.passkeyRegistrationOptions(PasskeyRegistrationOptionsRequest()) retryOnNetworkFailure {
if (!optionsResponse.isSuccessful) error("Passkey-Erstellung konnte nicht gestartet werden.") val optionsResponse = api.passkeyRegistrationOptions(PasskeyRegistrationOptionsRequest())
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options") if (!optionsResponse.isSuccessful) error("Passkey-Erstellung konnte nicht gestartet werden.")
?: error("Der Server hat keine Passkey-Optionen geliefert.") val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
?: error("Der Server hat keine Passkey-Optionen geliefert.")
val credentialManager = CredentialManager.create(context) val credentialManager = CredentialManager.create(context)
val credentialResponse = credentialManager.createCredential( val credentialResponse = credentialManager.createCredential(
context = context, context = context,
request = CreatePublicKeyCredentialRequest(optionsJson), request = CreatePublicKeyCredentialRequest(optionsJson),
) as? CreatePublicKeyCredentialResponse ) as? CreatePublicKeyCredentialResponse
?: error("Der erstellte Zugang ist kein Passkey.") ?: error("Der erstellte Zugang ist kein Passkey.")
val response = api.registerPasskey( val response = api.registerPasskey(
JSONObject() JSONObject()
.put("credential", JSONObject(credentialResponse.registrationResponseJson)) .put("credential", JSONObject(credentialResponse.registrationResponseJson))
.put("name", name) .put("name", name)
.put("client", "android") .put("client", "android")
.toString() .toString()
.toRequestBody(MediaTypes.json), .toRequestBody(MediaTypes.json),
) )
if (!response.isSuccessful) error("Passkey konnte nicht hinzugefügt werden.") if (!response.isSuccessful) error("Passkey konnte nicht hinzugefügt werden.")
response.body() ?: error("Leere Antwort") response.body() ?: error("Leere Antwort")
}
}.recoverCredentialCancellation("Passkey-Erstellung abgebrochen.") }.recoverCredentialCancellation("Passkey-Erstellung abgebrochen.")
suspend fun remove(credentialId: String): Result<AuthMessageResponse> = runCatching { suspend fun remove(credentialId: String): Result<AuthMessageResponse> = runCatching {

View File

@@ -9,14 +9,18 @@ import javax.inject.Singleton
@Singleton @Singleton
class ProfileRepository @Inject constructor(private val api: ApiService) { class ProfileRepository @Inject constructor(private val api: ApiService) {
suspend fun load(): Result<ProfileResponse> = runCatching { suspend fun load(): Result<ProfileResponse> = runCatching {
val response = api.profile() retryOnNetworkFailure {
if (!response.isSuccessful) error("Profil konnte nicht geladen werden.") val response = api.profile()
response.body() ?: error("Leere Antwort") if (!response.isSuccessful) error("Profil konnte nicht geladen werden.")
response.body() ?: error("Leere Antwort")
}
} }
suspend fun save(request: ProfileUpdateRequest): Result<ProfileResponse> = runCatching { suspend fun save(request: ProfileUpdateRequest): Result<ProfileResponse> = runCatching {
val response = api.updateProfile(request) retryOnNetworkFailure {
if (!response.isSuccessful) error("Profil konnte nicht gespeichert werden.") val response = api.updateProfile(request)
response.body() ?: error("Leere Antwort") if (!response.isSuccessful) error("Profil konnte nicht gespeichert werden.")
response.body() ?: error("Leere Antwort")
}
} }
} }

View File

@@ -31,43 +31,49 @@ data class MeisterschaftResult(
@Singleton @Singleton
class PublicPagesRepository @Inject constructor(private val api: ApiService) { class PublicPagesRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching { suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
val response = api.config() retryOnNetworkFailure {
if (!response.isSuccessful) error("Inhalte konnten nicht geladen werden.") val response = api.config()
response.body() ?: error("Leere Antwort") if (!response.isSuccessful) error("Inhalte konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort")
}
} }
suspend fun fetchSpielsysteme(): Result<List<Spielsystem>> = runCatching { suspend fun fetchSpielsysteme(): Result<List<Spielsystem>> = runCatching {
val response = api.spielsysteme() retryOnNetworkFailure {
if (!response.isSuccessful) error("Spielsysteme konnten nicht geladen werden.") val response = api.spielsysteme()
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values -> if (!response.isSuccessful) error("Spielsysteme konnten nicht geladen werden.")
if (values.size < 8) return@mapNotNull null parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
Spielsystem( if (values.size < 8) return@mapNotNull null
name = values[0], Spielsystem(
description = values[1], name = values[0],
teamSize = values[2], description = values[1],
category = values[3], teamSize = values[2],
sequence = values[5], category = values[3],
gameCount = values[6], sequence = values[5],
features = values[7], gameCount = values[6],
) features = values[7],
)
}
} }
} }
suspend fun fetchVereinsmeisterschaften(): Result<List<MeisterschaftResult>> = runCatching { suspend fun fetchVereinsmeisterschaften(): Result<List<MeisterschaftResult>> = runCatching {
val response = api.vereinsmeisterschaften() retryOnNetworkFailure {
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.") val response = api.vereinsmeisterschaften()
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values -> if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.")
if (values.size < 6) return@mapNotNull null parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
MeisterschaftResult( if (values.size < 6) return@mapNotNull null
year = values[0], MeisterschaftResult(
category = values[1], year = values[0],
rank = values[2], category = values[1],
playerOne = values[3], rank = values[2],
playerTwo = values[4], playerOne = values[3],
note = values[5], playerTwo = values[4],
imageOne = values.getOrElse(6) { "" }, note = values[5],
imageTwo = values.getOrElse(7) { "" }, imageOne = values.getOrElse(6) { "" },
) imageTwo = values.getOrElse(7) { "" },
)
}
} }
} }
} }

View File

@@ -0,0 +1,35 @@
package de.harheimertc.repositories
import android.util.Log
import com.google.firebase.messaging.FirebaseMessaging
import de.harheimertc.BuildConfig
import de.harheimertc.data.ApiService
import de.harheimertc.data.PushTokenRequest
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PushTokenRepository @Inject constructor(
private val api: ApiService,
) {
suspend fun registerCurrentDevice(): Result<Unit> = runCatching {
val token = FirebaseMessaging.getInstance().token.await()
registerToken(token).getOrThrow()
}
suspend fun registerToken(token: String): Result<Unit> = runCatching {
if (token.isBlank()) return@runCatching
retryOnNetworkFailure {
val response = api.registerPushToken(
PushTokenRequest(
token = token,
appVersion = "${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}",
),
)
if (!response.isSuccessful) error("Push-Token konnte nicht registriert werden.")
}
}.onFailure { error ->
Log.w("PushTokenRepository", "Push-Token Registrierung fehlgeschlagen", error)
}
}

View File

@@ -9,18 +9,22 @@ import javax.inject.Singleton
@Singleton @Singleton
class SpielplanRepository @Inject constructor(private val api: ApiService) { class SpielplanRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchSpielplan(season: String? = null): Result<SpielplanResponse> = runCatching { suspend fun fetchSpielplan(season: String? = null): Result<SpielplanResponse> = runCatching {
val response = api.spielplan(season) retryOnNetworkFailure {
if (!response.isSuccessful) error("HTTP ${response.code()}") val response = api.spielplan(season)
val body = response.body() ?: error("Leere Antwort") if (!response.isSuccessful) error("HTTP ${response.code()}")
if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.") val body = response.body() ?: error("Leere Antwort")
body if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.")
body
}
} }
suspend fun fetchTeamTable(team: String, season: String? = null): Result<TeamTableResponse> = runCatching { suspend fun fetchTeamTable(team: String, season: String? = null): Result<TeamTableResponse> = runCatching {
val response = api.spielplanTable(team, season) retryOnNetworkFailure {
if (!response.isSuccessful) error("HTTP ${response.code()}") val response = api.spielplanTable(team, season)
val body = response.body() ?: error("Leere Antwort") if (!response.isSuccessful) error("HTTP ${response.code()}")
if (!body.success) error(body.message ?: "Tabelle konnte nicht geladen werden.") val body = response.body() ?: error("Leere Antwort")
body if (!body.success) error(body.message ?: "Tabelle konnte nicht geladen werden.")
body
}
} }
} }

View File

@@ -8,8 +8,10 @@ import javax.inject.Singleton
@Singleton @Singleton
class TermineRepository @Inject constructor(private val api: ApiService) { class TermineRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchTermine(): Result<List<TerminDto>> = runCatching { suspend fun fetchTermine(): Result<List<TerminDto>> = runCatching {
val response = api.termine() retryOnNetworkFailure {
if (!response.isSuccessful) error("HTTP ${response.code()}") val response = api.termine()
response.body()?.termine.orEmpty() if (!response.isSuccessful) error("HTTP ${response.code()}")
response.body()?.termine.orEmpty()
}
} }
} }

View File

@@ -8,8 +8,10 @@ import javax.inject.Singleton
@Singleton @Singleton
class TrainingRepository @Inject constructor(private val api: ApiService) { class TrainingRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching { suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
val response = api.config() retryOnNetworkFailure {
if (!response.isSuccessful) error("Trainingsinformationen konnten nicht geladen werden.") val response = api.config()
response.body() ?: error("Leere Antwort") if (!response.isSuccessful) error("Trainingsinformationen konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort")
}
} }
} }

View File

@@ -6,9 +6,9 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -27,6 +27,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.harheimertc.BuildConfig import de.harheimertc.BuildConfig
import de.harheimertc.R import de.harheimertc.R
@@ -42,14 +43,17 @@ private enum class MenuSection {
TRAINING, TRAINING,
NEWSLETTER, NEWSLETTER,
INTERN, INTERN,
CMS,
} }
private data class MenuTarget(val label: String, val route: String) private data class MenuTarget(val label: String, val route: String)
private const val LOGOUT_ROUTE = "__logout__"
@Composable @Composable
fun AppNavigationHeader( fun AppNavigationHeader(
selectedRoute: String?, selectedRoute: String?,
onNavigate: (String) -> Unit, onNavigate: (String) -> Unit,
onLogout: () -> Unit = {},
webTabletNavigation: Boolean = false, webTabletNavigation: Boolean = false,
navigationState: NavigationUiState = NavigationUiState(), navigationState: NavigationUiState = NavigationUiState(),
) { ) {
@@ -61,9 +65,9 @@ fun AppNavigationHeader(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
if (webTabletNavigation) { if (webTabletNavigation) {
WebTabletNavigation(selectedRoute, onNavigate, navigationState) WebTabletNavigation(selectedRoute, onNavigate, onLogout, navigationState)
} else { } else {
CompactNavigation(selectedRoute, onNavigate, navigationState) CompactNavigation(selectedRoute, onNavigate, onLogout, navigationState)
} }
} }
} }
@@ -72,110 +76,56 @@ fun AppNavigationHeader(
private fun CompactNavigation( private fun CompactNavigation(
selectedRoute: String?, selectedRoute: String?,
onNavigate: (String) -> Unit, onNavigate: (String) -> Unit,
onLogout: () -> Unit,
navigationState: NavigationUiState = NavigationUiState(), navigationState: NavigationUiState = NavigationUiState(),
) { ) {
val routeSection = menuSection(selectedRoute) val routeSection = menuSection(selectedRoute)
val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) } val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
val section = routeSection ?: sectionOverride.value val section = routeSection ?: sectionOverride.value
val subItems = submenu(section, navigationState) val subItems = submenu(section, navigationState)
var cmsExpanded = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
val navigateAndClose: (String) -> Unit = { route -> cmsExpanded.value = false; onNavigate(route) }
val mainScroll = rememberScrollState() val mainScroll = rememberScrollState()
val subScroll = rememberScrollState() val subScroll = rememberScrollState()
val cmsSubScroll = rememberScrollState()
BrandRow(onLogin = { onNavigate(Destinations.Login.route) }) BrandRow(
Box(modifier = Modifier.fillMaxWidth()) { loggedIn = navigationState.loggedIn,
Row( onLogin = { onNavigate(Destinations.Login.route) },
modifier = Modifier.horizontalScroll(mainScroll), onLogout = onLogout,
horizontalArrangement = Arrangement.spacedBy(4.dp), )
) {
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate) { sectionOverride.value = null }
CompactSectionLink("Verein", MenuSection.VEREIN, section) { sectionOverride.value = MenuSection.VEREIN }
CompactSectionLink("Mannschaften", MenuSection.MANNSCHAFTEN, section) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
CompactSectionLink("Training", MenuSection.TRAINING, section) { sectionOverride.value = MenuSection.TRAINING }
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate) { sectionOverride.value = null }
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
CompactSectionLink("Newsletter", MenuSection.NEWSLETTER, section) { sectionOverride.value = MenuSection.NEWSLETTER }
if (navigationState.showGallery) {
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.VEREIN }
}
if (navigationState.loggedIn) {
CompactSectionLink("Intern", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN }
}
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate) { sectionOverride.value = null }
if (navigationState.isAdmin || navigationState.canAccessNewsletter || navigationState.canAccessContactRequests) {
CompactSectionLink("CMS", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN }
}
}
if (mainScroll.canScrollBackward) { ScrollableMenuRow(scrollState = mainScroll) {
Text( CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate) { sectionOverride.value = null }
"", CompactSectionLink("Verein", MenuSection.VEREIN, section) { sectionOverride.value = MenuSection.VEREIN }
color = Color(0xFFD4D4D8), CompactSectionLink("Mannschaften", MenuSection.MANNSCHAFTEN, section) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
style = MaterialTheme.typography.labelSmall, CompactSectionLink("Training", MenuSection.TRAINING, section) { sectionOverride.value = MenuSection.TRAINING }
modifier = Modifier CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate) { sectionOverride.value = null }
.align(Alignment.CenterStart) CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
.background(Color(0x66000000), RoundedCornerShape(8.dp)) CompactSectionLink("Newsletter", MenuSection.NEWSLETTER, section) { sectionOverride.value = MenuSection.NEWSLETTER }
.padding(horizontal = 4.dp, vertical = 2.dp), if (navigationState.showGallery) {
) CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.VEREIN }
} }
if (mainScroll.canScrollForward) { if (navigationState.loggedIn) {
Text( CompactSectionLink("Intern", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN }
"", if (navigationState.canAccessCms) {
color = Color(0xFFD4D4D8), CompactSectionLink("CMS", MenuSection.CMS, section) { sectionOverride.value = MenuSection.CMS }
style = MaterialTheme.typography.labelSmall, }
modifier = Modifier } else {
.align(Alignment.CenterEnd) CompactLink("Login", Destinations.Login.route, selectedRoute, onNavigate) { sectionOverride.value = null }
.background(Color(0x66000000), RoundedCornerShape(8.dp))
.padding(horizontal = 4.dp, vertical = 2.dp),
)
} }
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate) { sectionOverride.value = null }
} }
val cmsIndex = subItems.indexOfFirst { it.label == "CMS" } ScrollableMenuRow(scrollState = subScroll, topPadding = 3.dp) {
val cmsChildren = if (cmsIndex >= 0) subItems.subList(cmsIndex + 1, subItems.size) else emptyList<MenuTarget>() subItems.forEach { item ->
if (cmsChildren.any { it.route == selectedRoute }) { SubLink(item.label, item.route == selectedRoute) {
cmsExpanded.value = true if (item.route == LOGOUT_ROUTE) {
} onLogout()
} else {
Row( onNavigate(item.route)
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(subScroll)
.padding(top = 3.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
subItems.forEachIndexed { idx, item ->
if (idx == cmsIndex) {
SubLink(item.label, item.route == selectedRoute) {
cmsExpanded.value = !cmsExpanded.value
} }
} else if (idx > cmsIndex && cmsIndex >= 0) {
// CMS children are rendered below when expanded.
} else {
SubLink(item.label, item.route == selectedRoute) { navigateAndClose(item.route) }
} }
} }
} }
if (subItems.isNotEmpty()) {
ScrollHintRow(subScroll)
}
if (cmsExpanded.value && cmsChildren.isNotEmpty()) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(cmsSubScroll)
.padding(top = 6.dp, bottom = 3.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
cmsChildren.forEach { child ->
SubLink(child.label, child.route == selectedRoute) { onNavigate(child.route) }
}
}
ScrollHintRow(cmsSubScroll)
}
} }
@Composable @Composable
@@ -206,12 +156,13 @@ private fun CompactSectionLink(
private fun WebTabletNavigation( private fun WebTabletNavigation(
selectedRoute: String?, selectedRoute: String?,
onNavigate: (String) -> Unit, onNavigate: (String) -> Unit,
onLogout: () -> Unit,
navigationState: NavigationUiState, navigationState: NavigationUiState,
) { ) {
val section = menuSection(selectedRoute) val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
var cmsExpanded = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) } val section = sectionOverride.value ?: menuSection(selectedRoute)
// Helper that closes the CMS submenu when navigating away val subScroll = rememberScrollState()
val navigateAndClose: (String) -> Unit = { route -> cmsExpanded.value = false; onNavigate(route) }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Brand() Brand()
Spacer(Modifier.width(16.dp)) Spacer(Modifier.width(16.dp))
@@ -220,74 +171,91 @@ private fun WebTabletNavigation(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
MainLink("Start", selectedRoute == Destinations.Home.route, onClick = { navigateAndClose(Destinations.Home.route) }) MainLink("Start", selectedRoute == Destinations.Home.route, onClick = {
MainLink("Verein", section == MenuSection.VEREIN, onClick = { navigateAndClose(Destinations.VereinAbout.route) }) sectionOverride.value = null
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { navigateAndClose(Destinations.Mannschaften.route) }) onNavigate(Destinations.Home.route)
MainLink("Training", section == MenuSection.TRAINING, onClick = { navigateAndClose(Destinations.Training.route) }) })
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = { navigateAndClose(Destinations.Termine.route) }) MainLink("Verein", section == MenuSection.VEREIN, onClick = {
sectionOverride.value = MenuSection.VEREIN
onNavigate(Destinations.VereinAbout.route)
})
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = {
sectionOverride.value = MenuSection.MANNSCHAFTEN
onNavigate(Destinations.Mannschaften.route)
})
MainLink("Training", section == MenuSection.TRAINING, onClick = {
sectionOverride.value = MenuSection.TRAINING
onNavigate(Destinations.Training.route)
})
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = {
sectionOverride.value = null
onNavigate(Destinations.Termine.route)
})
if (navigationState.showGallery) { if (navigationState.showGallery) {
MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = { onNavigate(Destinations.Gallery.route) }) MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = {
sectionOverride.value = MenuSection.VEREIN
onNavigate(Destinations.Gallery.route)
})
} }
MainLink("Newsletter", section == MenuSection.NEWSLETTER, onClick = { onNavigate(Destinations.NewsletterSubscribe.route) }) MainLink("Newsletter", section == MenuSection.NEWSLETTER, onClick = {
sectionOverride.value = MenuSection.NEWSLETTER
onNavigate(Destinations.NewsletterSubscribe.route)
})
if (navigationState.loggedIn) { if (navigationState.loggedIn) {
MainLink("Intern", section == MenuSection.INTERN, onClick = { onNavigate(Destinations.MemberArea.route) }) MainLink("Intern", section == MenuSection.INTERN, onClick = {
} sectionOverride.value = MenuSection.INTERN
MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = { navigateAndClose(Destinations.Contact.route) }) })
TextButton(onClick = { navigateAndClose(Destinations.Login.route) }) { Text("Login", color = Color.White) } if (navigationState.canAccessCms) {
} MainLink("CMS", section == MenuSection.CMS, onClick = {
} sectionOverride.value = MenuSection.CMS
val subItems = submenu(section, navigationState) onNavigate(Destinations.Cms.route)
// determine CMS parent index and children })
val cmsIndex = subItems.indexOfFirst { it.label == "CMS" }
val cmsChildren = if (cmsIndex >= 0) subItems.subList(cmsIndex + 1, subItems.size) else emptyList<MenuTarget>()
if (cmsChildren.any { it.route == selectedRoute }) {
cmsExpanded.value = true
}
// First row: render all subitems but do NOT render CMS children here
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(top = 3.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
subItems.forEachIndexed { idx, item ->
if (idx == cmsIndex) {
// CMS parent toggle
SubLink(item.label, item.route == selectedRoute) {
cmsExpanded.value = !cmsExpanded.value
} }
} else if (idx > cmsIndex && cmsIndex >= 0) {
// skip cms children here; they'll be rendered in the second row when expanded
} else {
// normal item before CMS: close cms submenu on navigate
SubLink(item.label, item.route == selectedRoute) { navigateAndClose(item.route) }
} }
MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = {
sectionOverride.value = null
onNavigate(Destinations.Contact.route)
})
}
Spacer(Modifier.width(12.dp))
if (navigationState.loggedIn) {
TextButton(onClick = onLogout) { Text("Logout", color = Color.White) }
} else {
TextButton(onClick = {
sectionOverride.value = null
onNavigate(Destinations.Login.route)
}) { Text("Login", color = Color.White) }
} }
} }
// Second row: when CMS expanded, render its children beneath val subItems = submenu(section, navigationState)
if (cmsExpanded.value && cmsChildren.isNotEmpty()) { ScrollableMenuRow(scrollState = subScroll, topPadding = 3.dp) {
Row( subItems.forEach { item ->
modifier = Modifier SubLink(item.label, item.route == selectedRoute) {
.fillMaxWidth() if (item.route == LOGOUT_ROUTE) {
.horizontalScroll(rememberScrollState()) onLogout()
.padding(top = 6.dp, bottom = 3.dp), } else {
horizontalArrangement = Arrangement.spacedBy(8.dp), onNavigate(item.route)
) { }
cmsChildren.forEach { child ->
SubLink(child.label, child.route == selectedRoute) { onNavigate(child.route) }
} }
} }
} }
} }
@Composable @Composable
private fun BrandRow(onLogin: () -> Unit) { private fun BrandRow(
loggedIn: Boolean,
onLogin: () -> Unit,
onLogout: () -> Unit,
) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Brand() Brand()
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
TextButton(onClick = onLogin) { Text("Login", color = Color.White) } if (loggedIn) {
TextButton(onClick = onLogout) { Text("Logout", color = Color.White) }
} else {
TextButton(onClick = onLogin) { Text("Login", color = Color.White) }
}
} }
} }
@@ -367,25 +335,36 @@ private fun CompactLink(
} }
@Composable @Composable
private fun ScrollHintRow(scrollState: ScrollState) { private fun ScrollableMenuRow(
if (!scrollState.canScrollBackward && !scrollState.canScrollForward) return scrollState: ScrollState,
topPadding: Dp = 0.dp,
content: @Composable RowScope.() -> Unit,
) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 2.dp), .padding(top = topPadding),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
if (scrollState.canScrollBackward) "" else "", if (scrollState.canScrollBackward) "" else "",
color = Color(0xFFD4D4D8), color = Color(0xFFD4D4D8),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
modifier = Modifier.width(14.dp),
)
Row(
modifier = Modifier
.weight(1f)
.horizontalScroll(scrollState),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
content = content,
) )
Text( Text(
if (scrollState.canScrollForward) "" else "", if (scrollState.canScrollForward) "" else "",
color = Color(0xFFD4D4D8), color = Color(0xFFD4D4D8),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
modifier = Modifier.width(14.dp),
) )
} }
} }
@@ -431,12 +410,15 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
Destinations.NewsletterConfirm.route, Destinations.NewsletterConfirm.route,
Destinations.NewsletterConfirmed.route, Destinations.NewsletterConfirmed.route,
Destinations.NewsletterUnsubscribed.route -> MenuSection.NEWSLETTER Destinations.NewsletterUnsubscribed.route -> MenuSection.NEWSLETTER
Destinations.MemberArea.route, Destinations.MemberArea.route,
Destinations.Members.route, Destinations.Members.route,
Destinations.Qttr.route, Destinations.Qttr.route,
Destinations.MemberNews.route, Destinations.MemberNews.route,
Destinations.Profile.route, Destinations.Profile.route,
Destinations.MemberApi.route, Destinations.NotificationSettings.route,
Destinations.MemberApi.route -> MenuSection.INTERN
Destinations.CmsStartseite.route, Destinations.CmsStartseite.route,
Destinations.CmsInhalte.route, Destinations.CmsInhalte.route,
Destinations.CmsVereinsmeisterschaften.route, Destinations.CmsVereinsmeisterschaften.route,
@@ -447,7 +429,8 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
Destinations.CmsEinstellungen.route, Destinations.CmsEinstellungen.route,
Destinations.CmsBenutzer.route, Destinations.CmsBenutzer.route,
Destinations.CmsPasswordResetDiagnostics.route, Destinations.CmsPasswordResetDiagnostics.route,
Destinations.Cms.route -> MenuSection.INTERN Destinations.Cms.route -> MenuSection.CMS
else -> null else -> null
}.let { section -> }.let { section ->
if (section == null && route?.startsWith("mannschaften/") == true) MenuSection.MANNSCHAFTEN else section if (section == null && route?.startsWith("mannschaften/") == true) MenuSection.MANNSCHAFTEN else section
@@ -464,43 +447,52 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
MenuTarget("Links", Destinations.Links.route), MenuTarget("Links", Destinations.Links.route),
MenuTarget("Impressum", Destinations.Impressum.route), MenuTarget("Impressum", Destinations.Impressum.route),
) )
MenuSection.MANNSCHAFTEN -> listOf( MenuSection.MANNSCHAFTEN -> listOf(
MenuTarget("Übersicht", Destinations.Mannschaften.route), MenuTarget("Übersicht", Destinations.Mannschaften.route),
) + state.teams.map { MenuTarget(it.mannschaft, Destinations.MannschaftDetail.create(it.slug)) } + listOf( ) + state.teams.map { MenuTarget(it.mannschaft, Destinations.MannschaftDetail.create(it.slug)) } + listOf(
MenuTarget("Spielpläne", Destinations.Spielplan.route), MenuTarget("Spielpläne", Destinations.Spielplan.route),
MenuTarget("Spielsysteme", Destinations.Spielsysteme.route), MenuTarget("Spielsysteme", Destinations.Spielsysteme.route),
) )
MenuSection.TRAINING -> listOf( MenuSection.TRAINING -> listOf(
MenuTarget("Trainingszeiten", Destinations.Training.route), MenuTarget("Trainingszeiten", Destinations.Training.route),
MenuTarget("Trainer", Destinations.Trainer.route), MenuTarget("Trainer", Destinations.Trainer.route),
MenuTarget("Anfänger", Destinations.Anfaenger.route), MenuTarget("Anfänger", Destinations.Anfaenger.route),
MenuTarget("TT-Regeln", Destinations.Regeln.route), MenuTarget("TT-Regeln", Destinations.Regeln.route),
) )
MenuSection.NEWSLETTER -> listOf( MenuSection.NEWSLETTER -> listOf(
MenuTarget("Abonnieren", Destinations.NewsletterSubscribe.route), MenuTarget("Abonnieren", Destinations.NewsletterSubscribe.route),
MenuTarget("Abmelden", Destinations.NewsletterUnsubscribe.route), MenuTarget("Abmelden", Destinations.NewsletterUnsubscribe.route),
MenuTarget("Bestätigt", Destinations.NewsletterConfirmed.route), MenuTarget("Bestätigt", Destinations.NewsletterConfirmed.route),
) )
MenuSection.INTERN -> buildList { MenuSection.INTERN -> buildList {
add(MenuTarget("Übersicht", Destinations.MemberArea.route)) add(MenuTarget("Übersicht", Destinations.MemberArea.route))
add(MenuTarget("Mitgliederliste", Destinations.Members.route)) add(MenuTarget("Mitgliederliste", Destinations.Members.route))
add(MenuTarget("QTTR", Destinations.Qttr.route)) add(MenuTarget("QTTR", Destinations.Qttr.route))
add(MenuTarget("News", Destinations.MemberNews.route)) add(MenuTarget("News", Destinations.MemberNews.route))
add(MenuTarget("Mein Profil", Destinations.Profile.route)) add(MenuTarget("Mein Profil", Destinations.Profile.route))
add(MenuTarget("Benachrichtigungen", Destinations.NotificationSettings.route))
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route)) add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route))
if (state.isAdmin || state.canAccessContactRequests || state.canAccessNewsletter) add(MenuTarget("CMS", Destinations.Cms.route)) }
// CMS child items (will be rendered when CMS parent is expanded)
if (state.isAdmin || state.canAccessContactRequests || state.canAccessNewsletter) add(MenuTarget("Übersicht", Destinations.Cms.route)) MenuSection.CMS -> buildList {
if (state.isAdmin) add(MenuTarget("Startseite", Destinations.CmsStartseite.route)) if (state.canAccessFullCms) {
if (state.isAdmin) add(MenuTarget("Inhalte", Destinations.CmsInhalte.route)) add(MenuTarget("Übersicht", Destinations.Cms.route))
if (state.isAdmin) add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route)) add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
if (state.isAdmin) add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route)) add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
if (state.isAdmin) add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route)) add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
add(MenuTarget("News", Destinations.MemberNews.route))
add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route))
add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
add(MenuTarget("Benutzerverwaltung", Destinations.CmsBenutzer.route))
}
if (state.canAccessNewsletter) add(MenuTarget("Newsletter", Destinations.CmsNewsletter.route)) if (state.canAccessNewsletter) add(MenuTarget("Newsletter", Destinations.CmsNewsletter.route))
if (state.canAccessContactRequests) add(MenuTarget("Kontaktanfragen", Destinations.CmsContactRequests.route)) if (state.canAccessContactRequests) add(MenuTarget("Kontaktanfragen", Destinations.CmsContactRequests.route))
if (state.isAdmin) add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
if (state.isAdmin) add(MenuTarget("Benutzer", Destinations.CmsBenutzer.route))
if (state.isAdmin) add(MenuTarget("Reset-Diagnose", Destinations.CmsPasswordResetDiagnostics.route))
} }
null -> emptyList() null -> emptyList()
} }

View File

@@ -0,0 +1,24 @@
package de.harheimertc.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Primary600
@Composable
internal fun LoadingState(message: String = "Daten werden geladen...") {
Column(
modifier = Modifier.fillMaxWidth().padding(28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator(color = Primary600)
Text(message, color = Accent500, modifier = Modifier.padding(top = 12.dp))
}
}

View File

@@ -10,8 +10,12 @@ sealed class Destinations(val route: String) {
object Links : Destinations("verein/links") object Links : Destinations("verein/links")
object Impressum : Destinations("impressum") object Impressum : Destinations("impressum")
object Mannschaften : Destinations("mannschaften") object Mannschaften : Destinations("mannschaften")
object MannschaftDetail : Destinations("mannschaften/{slug}") { object MannschaftDetail : Destinations("mannschaften/{slug}?season={season}") {
fun create(slug: String): String = "mannschaften/$slug" fun create(slug: String, season: String? = null): String {
val encodedSlug = android.net.Uri.encode(slug)
val selectedSeason = season?.takeIf { it.isNotBlank() } ?: return "mannschaften/$encodedSlug"
return "mannschaften/$encodedSlug?season=${android.net.Uri.encode(selectedSeason)}"
}
} }
object MannschaftLegacyDetail : Destinations("mannschaft/{slug}") { object MannschaftLegacyDetail : Destinations("mannschaft/{slug}") {
fun create(slug: String): String = "mannschaft/$slug" fun create(slug: String): String = "mannschaft/$slug"
@@ -39,6 +43,7 @@ sealed class Destinations(val route: String) {
object Qttr : Destinations("intern/qttr") object Qttr : Destinations("intern/qttr")
object MemberNews : Destinations("intern/news") object MemberNews : Destinations("intern/news")
object Profile : Destinations("intern/profil") object Profile : Destinations("intern/profil")
object NotificationSettings : Destinations("intern/benachrichtigungen")
object MemberApi : Destinations("intern/api") object MemberApi : Destinations("intern/api")
object CmsStartseite : Destinations("cms/startseite") object CmsStartseite : Destinations("cms/startseite")
object CmsInhalte : Destinations("cms/inhalte") object CmsInhalte : Destinations("cms/inhalte")

View File

@@ -2,15 +2,22 @@ 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.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column 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.padding
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -26,7 +33,9 @@ fun NavGraph(
val backStackEntry = navController.currentBackStackEntryAsState().value val backStackEntry = navController.currentBackStackEntryAsState().value
val route = backStackEntry?.destination?.route val route = backStackEntry?.destination?.route
val currentRoute = if (route == Destinations.MannschaftDetail.route) { val currentRoute = if (route == Destinations.MannschaftDetail.route) {
backStackEntry.arguments?.getString("slug")?.let(Destinations.MannschaftDetail::create) backStackEntry.arguments?.getString("slug")?.let { slug ->
Destinations.MannschaftDetail.create(slug, backStackEntry.arguments?.getString("season"))
}
} else route } else route
val navigationState by navigationViewModel.state.collectAsState() val navigationState by navigationViewModel.state.collectAsState()
LaunchedEffect(currentRoute) { LaunchedEffect(currentRoute) {
@@ -35,10 +44,27 @@ fun NavGraph(
BoxWithConstraints(modifier = Modifier.fillMaxSize()) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val persistentNavigation = maxWidth >= 600.dp val persistentNavigation = maxWidth >= 600.dp
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
navigationState.connectionNote?.let { message ->
Surface(color = Color(0xFFFFF4E5), modifier = Modifier.fillMaxWidth()) {
Text(
text = message,
color = Color(0xFF7C2D12),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
)
}
}
if (persistentNavigation) { if (persistentNavigation) {
AppNavigationHeader( AppNavigationHeader(
selectedRoute = currentRoute, selectedRoute = currentRoute,
onNavigate = navController::navigateTopLevel, onNavigate = navController::navigateTopLevel,
onLogout = {
navigationViewModel.logout {
navController.navigate(Destinations.Home.route) {
launchSingleTop = true
}
}
},
webTabletNavigation = true, webTabletNavigation = true,
navigationState = navigationState, navigationState = navigationState,
) )
@@ -52,6 +78,8 @@ fun NavGraph(
de.harheimertc.ui.screens.home.HomeScreen( de.harheimertc.ui.screens.home.HomeScreen(
navController = navController, navController = navController,
showNavigationHeader = !persistentNavigation, showNavigationHeader = !persistentNavigation,
navigationViewModel = navigationViewModel,
viewModel = hiltViewModel(),
) )
} }
composable(Destinations.VereinAbout.route) { composable(Destinations.VereinAbout.route) {
@@ -111,9 +139,13 @@ fun NavGraph(
composable("mannschaften/jugend") { composable("mannschaften/jugend") {
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation) de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation)
} }
composable(Destinations.MannschaftDetail.route) { entry -> composable(
route = Destinations.MannschaftDetail.route,
arguments = listOf(navArgument("season") { nullable = true; defaultValue = null }),
) { entry ->
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen( de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
slug = entry.arguments?.getString("slug").orEmpty(), slug = entry.arguments?.getString("slug").orEmpty(),
season = entry.arguments?.getString("season"),
navController = navController, navController = navController,
showBackNavigation = !persistentNavigation, showBackNavigation = !persistentNavigation,
) )
@@ -121,6 +153,7 @@ fun NavGraph(
composable(Destinations.MannschaftLegacyDetail.route) { entry -> composable(Destinations.MannschaftLegacyDetail.route) { entry ->
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen( de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
slug = entry.arguments?.getString("slug").orEmpty(), slug = entry.arguments?.getString("slug").orEmpty(),
season = null,
navController = navController, navController = navController,
showBackNavigation = !persistentNavigation, showBackNavigation = !persistentNavigation,
) )
@@ -253,6 +286,7 @@ fun NavGraph(
de.harheimertc.ui.screens.memberarea.MemberAreaScreen( de.harheimertc.ui.screens.memberarea.MemberAreaScreen(
navController = navController, navController = navController,
showBackNavigation = !persistentNavigation, showBackNavigation = !persistentNavigation,
navigationState = navigationState,
) )
} }
composable(Destinations.Members.route) { composable(Destinations.Members.route) {
@@ -279,6 +313,13 @@ fun NavGraph(
showBackNavigation = !persistentNavigation, showBackNavigation = !persistentNavigation,
) )
} }
composable(Destinations.NotificationSettings.route) {
de.harheimertc.ui.screens.notifications.NotificationSettingsScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
navigationState = navigationState,
)
}
composable(Destinations.MemberApi.route) { composable(Destinations.MemberApi.route) {
de.harheimertc.ui.screens.memberarea.MemberApiScreen( de.harheimertc.ui.screens.memberarea.MemberApiScreen(
navController = navController, navController = navController,

View File

@@ -3,10 +3,13 @@ package de.harheimertc.ui.navigation
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ConnectivityMonitor
import de.harheimertc.repositories.AuthRepository
import de.harheimertc.repositories.GalleryRepository import de.harheimertc.repositories.GalleryRepository
import de.harheimertc.repositories.LoginRepository import de.harheimertc.repositories.LoginRepository
import de.harheimertc.repositories.Mannschaft import de.harheimertc.repositories.Mannschaft
import de.harheimertc.repositories.MannschaftenRepository import de.harheimertc.repositories.MannschaftenRepository
import de.harheimertc.repositories.PushTokenRepository
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -18,10 +21,13 @@ data class NavigationUiState(
val hasGalleryImages: Boolean = false, val hasGalleryImages: Boolean = false,
val loggedIn: Boolean = false, val loggedIn: Boolean = false,
val roles: Set<String> = emptySet(), val roles: Set<String> = emptySet(),
val connectionNote: String? = null,
) { ) {
val isAdmin: Boolean get() = "admin" in roles val isAdmin: Boolean get() = "admin" in roles
val canAccessFullCms: Boolean get() = roles.any { it in setOf("admin", "vorstand") }
val canAccessNewsletter: Boolean get() = roles.any { it in setOf("admin", "vorstand", "newsletter") } val canAccessNewsletter: Boolean get() = roles.any { it in setOf("admin", "vorstand", "newsletter") }
val canAccessContactRequests: Boolean get() = roles.any { it in setOf("admin", "vorstand", "trainer") } val canAccessContactRequests: Boolean get() = roles.any { it in setOf("admin", "vorstand", "trainer") }
val canAccessCms: Boolean get() = canAccessFullCms || canAccessNewsletter || canAccessContactRequests
val showGallery: Boolean get() = hasGalleryImages || canAccessNewsletter val showGallery: Boolean get() = hasGalleryImages || canAccessNewsletter
} }
@@ -30,12 +36,24 @@ class NavigationViewModel @Inject constructor(
private val mannschaftenRepository: MannschaftenRepository, private val mannschaftenRepository: MannschaftenRepository,
private val galleryRepository: GalleryRepository, private val galleryRepository: GalleryRepository,
private val loginRepository: LoginRepository, private val loginRepository: LoginRepository,
private val authRepository: AuthRepository,
private val connectivityMonitor: ConnectivityMonitor,
private val pushTokenRepository: PushTokenRepository,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(NavigationUiState()) private val _state = MutableStateFlow(NavigationUiState())
val state: StateFlow<NavigationUiState> = _state val state: StateFlow<NavigationUiState> = _state
init { init {
loadNavigationData() loadNavigationData()
viewModelScope.launch {
var wasOnline: Boolean? = null
connectivityMonitor.online.collect { online ->
_state.value = _state.value.copy(
connectionNote = if (online) null else "Keine Verbindung. Die App versucht alle 10 Sekunden erneut zu laden.",
)
wasOnline = online
}
}
} }
fun loadNavigationData() { fun loadNavigationData() {
@@ -44,22 +62,54 @@ class NavigationViewModel @Inject constructor(
val gallery = async { galleryRepository.hasPublicImages().getOrDefault(false) } val gallery = async { galleryRepository.hasPublicImages().getOrDefault(false) }
val auth = async { loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) } val auth = async { loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) }
val status = auth.await() val status = auth.await()
val hasStoredSession = !authRepository.getToken().isNullOrBlank()
val loggedIn = hasStoredSession || status.isLoggedIn
_state.value = NavigationUiState( _state.value = NavigationUiState(
teams = teams.await(), teams = teams.await(),
hasGalleryImages = gallery.await(), hasGalleryImages = gallery.await(),
loggedIn = status.isLoggedIn, loggedIn = loggedIn,
roles = (status.roles + status.user?.roles.orEmpty()).toSet(), roles = status.navigationRoles(),
connectionNote = null,
) )
if (loggedIn) registerPushToken()
} }
} }
fun refreshSession() { fun refreshSession() {
viewModelScope.launch { viewModelScope.launch {
val status = loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) val status = loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse())
val hasStoredSession = !authRepository.getToken().isNullOrBlank()
val loggedIn = hasStoredSession || status.isLoggedIn
_state.value = _state.value.copy( _state.value = _state.value.copy(
loggedIn = status.isLoggedIn, loggedIn = loggedIn,
roles = (status.roles + status.user?.roles.orEmpty()).toSet(), roles = status.navigationRoles(),
connectionNote = _state.value.connectionNote,
) )
if (loggedIn) registerPushToken()
}
}
private fun registerPushToken() {
viewModelScope.launch {
pushTokenRepository.registerCurrentDevice()
}
}
fun logout(onComplete: () -> Unit = {}) {
viewModelScope.launch {
loginRepository.logout()
_state.value = _state.value.copy(
loggedIn = false,
roles = emptySet(),
connectionNote = _state.value.connectionNote,
)
onComplete()
} }
} }
} }
private fun de.harheimertc.data.AuthStatusResponse.navigationRoles(): Set<String> = buildSet {
addAll(roles)
role?.takeIf { it.isNotBlank() }?.let(::add)
addAll(user?.roles.orEmpty())
}

View File

@@ -12,7 +12,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -36,6 +35,7 @@ import androidx.compose.ui.platform.LocalContext
import de.harheimertc.data.NewsDto import de.harheimertc.data.NewsDto
import de.harheimertc.data.NewsSaveRequest import de.harheimertc.data.NewsSaveRequest
import de.harheimertc.ui.components.FormMessages import de.harheimertc.ui.components.FormMessages
import de.harheimertc.ui.components.LoadingState
import de.harheimertc.ui.components.NativeRichTextEditor import de.harheimertc.ui.components.NativeRichTextEditor
import de.harheimertc.ui.navigation.Destinations import de.harheimertc.ui.navigation.Destinations
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -112,7 +112,7 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
} }
CmsPage(navController, showBackNavigation, "News", "Interne und öffentliche News") { CmsPage(navController, showBackNavigation, "News", "Interne und öffentliche News") {
if (state.loading) item { CircularProgressIndicator() } if (state.loading) item { LoadingState("News werden geladen...") }
item { item {
Button(onClick = { viewModel.load(); /* ensure latest */ }, modifier = Modifier.fillMaxWidth()) { Text("Neu laden") } Button(onClick = { viewModel.load(); /* ensure latest */ }, modifier = Modifier.fillMaxWidth()) { Text("Neu laden") }

View File

@@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -50,6 +49,7 @@ import de.harheimertc.data.PasswordResetMatchingUserDto
import de.harheimertc.data.PasswordResetAttemptDto import de.harheimertc.data.PasswordResetAttemptDto
import de.harheimertc.data.PasswordResetStepDto import de.harheimertc.data.PasswordResetStepDto
import de.harheimertc.ui.components.FormMessages import de.harheimertc.ui.components.FormMessages
import de.harheimertc.ui.components.LoadingState
import de.harheimertc.ui.components.NativeRichTextEditor import de.harheimertc.ui.components.NativeRichTextEditor
import de.harheimertc.ui.navigation.Destinations import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.repositories.MeisterschaftResult import de.harheimertc.repositories.MeisterschaftResult
@@ -67,7 +67,7 @@ import java.util.Locale
fun CmsDashboardScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { fun CmsDashboardScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
CmsPage(navController, showBackNavigation, "CMS", "Verwaltung und interne Werkzeuge") { CmsPage(navController, showBackNavigation, "CMS", "Verwaltung und interne Werkzeuge") {
if (state.loading) item { CircularProgressIndicator(color = Primary600) } if (state.loading) item { LoadingState("CMS-Daten werden geladen...") }
item { CmsSummaryGrid(navController, state) } item { CmsSummaryGrid(navController, state) }
} }
} }
@@ -85,7 +85,7 @@ fun CmsStartseiteScreen(navController: NavController, showBackNavigation: Boolea
CmsPage(navController, showBackNavigation, "Startseite konfigurieren", "Legen Sie die Reihenfolge der Elemente auf der Startseite fest.") { CmsPage(navController, showBackNavigation, "Startseite konfigurieren", "Legen Sie die Reihenfolge der Elemente auf der Startseite fest.") {
when { when {
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) } state.loading || config == null -> item { LoadingState("Startseitenkonfiguration wird geladen...") }
else -> { else -> {
item { item {
Button( Button(
@@ -171,7 +171,7 @@ fun CmsInhalteScreen(navController: NavController, showBackNavigation: Boolean,
CmsPage(navController, showBackNavigation, "Inhalte", "Vereinsseiten und strukturierte Inhalte") { CmsPage(navController, showBackNavigation, "Inhalte", "Vereinsseiten und strukturierte Inhalte") {
when { when {
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) } state.loading || config == null -> item { LoadingState("Inhalte werden geladen...") }
else -> { else -> {
item { item {
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) { Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
@@ -262,7 +262,7 @@ fun CmsVereinsmeisterschaftenScreen(navController: NavController, showBackNaviga
CmsPage(navController, showBackNavigation, "Vereinsmeisterschaften", "Ergebnisse und Hinweise als CSV-Inhalt bearbeiten") { CmsPage(navController, showBackNavigation, "Vereinsmeisterschaften", "Ergebnisse und Hinweise als CSV-Inhalt bearbeiten") {
when { when {
state.loading -> item { CircularProgressIndicator(color = Primary600) } state.loading -> item { LoadingState("Vereinsmeisterschaften werden geladen...") }
else -> { else -> {
item { item {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
@@ -456,7 +456,7 @@ fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Bool
CmsPage(navController, showBackNavigation, "Sportbetrieb", "Trainingszeiten, Trainingsort und Trainer pflegen") { CmsPage(navController, showBackNavigation, "Sportbetrieb", "Trainingszeiten, Trainingsort und Trainer pflegen") {
when { when {
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) } state.loading || config == null -> item { LoadingState("Sportbetriebsdaten werden geladen...") }
else -> { else -> {
item { item {
Button( Button(
@@ -555,7 +555,7 @@ fun CmsMitgliederverwaltungScreen(navController: NavController, showBackNavigati
fun CmsContactRequestsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { fun CmsContactRequestsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
CmsPage(navController, showBackNavigation, "Kontaktanfragen", "Eingegangene Nachrichten") { CmsPage(navController, showBackNavigation, "Kontaktanfragen", "Eingegangene Nachrichten") {
if (state.loading) item { CircularProgressIndicator(color = Primary600) } if (state.loading) item { LoadingState("Kontaktanfragen werden geladen...") }
if (!state.loading && state.contactRequests.isEmpty()) item { EmptyCard("Keine Kontaktanfragen gefunden.") } if (!state.loading && state.contactRequests.isEmpty()) item { EmptyCard("Keine Kontaktanfragen gefunden.") }
items(state.contactRequests.size) { index -> ContactRequestCard(state.contactRequests[index], viewModel) } items(state.contactRequests.size) { index -> ContactRequestCard(state.contactRequests[index], viewModel) }
} }
@@ -592,7 +592,7 @@ fun CmsNewsletterScreen(
var grpTargetGroup by remember { mutableStateOf("") } var grpTargetGroup by remember { mutableStateOf("") }
var grpSendToExternal by remember { mutableStateOf(true) } var grpSendToExternal by remember { mutableStateOf(true) }
CmsPage(navController, showBackNavigation, "Newsletter", "Newsletter und Gruppen") { CmsPage(navController, showBackNavigation, "Newsletter", "Newsletter und Gruppen") {
if (state.loading) item { CircularProgressIndicator(color = Primary600) } if (state.loading) item { LoadingState("Newsletter-Daten werden geladen...") }
item { item {
if (canWrite) Button(onClick = { if (canWrite) Button(onClick = {
editingNewsletter = null editingNewsletter = null
@@ -769,7 +769,7 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
CmsPage(navController, showBackNavigation, "Einstellungen", "Vereins- und Website-Konfiguration") { CmsPage(navController, showBackNavigation, "Einstellungen", "Vereins- und Website-Konfiguration") {
when { when {
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) } state.loading || config == null -> item { LoadingState("Einstellungen werden geladen...") }
else -> { else -> {
item { item {
Button( Button(
@@ -883,7 +883,7 @@ fun CmsPasswordResetDiagnosticsScreen(navController: NavController, showBackNavi
} }
if (state.loading) { if (state.loading) {
item { CircularProgressIndicator(color = Primary600) } item { LoadingState("Diagnosedaten werden geladen...") }
} }
if (state.passwordResetSearchTerm.isNotBlank()) { if (state.passwordResetSearchTerm.isNotBlank()) {
@@ -1073,7 +1073,7 @@ private fun CmsConfigPage(
) { ) {
CmsPage(navController, showBackNavigation, title, subtitle) { CmsPage(navController, showBackNavigation, title, subtitle) {
if (config == null) { if (config == null) {
item { CircularProgressIndicator(color = Primary600) } item { LoadingState("Konfiguration wird geladen...") }
} else { } else {
item { item {
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) { Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {

View File

@@ -3,6 +3,7 @@ package de.harheimertc.ui.screens.cms
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ConnectivityMonitor
import de.harheimertc.data.CmsUserDto import de.harheimertc.data.CmsUserDto
import de.harheimertc.data.ConfigResponse import de.harheimertc.data.ConfigResponse
import de.harheimertc.data.ContactRequestDto import de.harheimertc.data.ContactRequestDto
@@ -43,12 +44,22 @@ data class CmsUiState(
@HiltViewModel @HiltViewModel
class CmsViewModel @Inject constructor( class CmsViewModel @Inject constructor(
private val repository: CmsRepository, private val repository: CmsRepository,
private val connectivityMonitor: ConnectivityMonitor,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(CmsUiState()) private val _state = MutableStateFlow(CmsUiState())
val state: StateFlow<CmsUiState> = _state val state: StateFlow<CmsUiState> = _state
init { init {
load() load()
viewModelScope.launch {
var wasOnline: Boolean? = null
connectivityMonitor.online.collect { online ->
if (online && wasOnline == false) {
load()
}
wasOnline = online
}
}
} }
fun load() { fun load() {

View File

@@ -14,7 +14,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
@@ -36,6 +35,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import de.harheimertc.R import de.harheimertc.R
import de.harheimertc.ui.components.FormMessages import de.harheimertc.ui.components.FormMessages
import de.harheimertc.ui.components.LoadingState
import de.harheimertc.ui.components.ImageGrid import de.harheimertc.ui.components.ImageGrid
@Composable @Composable
@@ -131,7 +131,7 @@ fun GalleryScreen(viewModel: GalleryViewModel = hiltViewModel()) {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
if (loading) { if (loading) {
CircularProgressIndicator() LoadingState("Galerie wird geladen...")
} else if (images.isEmpty()) { } else if (images.isEmpty()) {
Text(text = stringResource(R.string.gallery_empty)) Text(text = stringResource(R.string.gallery_empty))
} else { } else {

View File

@@ -49,7 +49,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import de.harheimertc.ui.navigation.NavigationViewModel import de.harheimertc.ui.navigation.NavigationViewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import coil.compose.AsyncImage import coil.compose.AsyncImage
@@ -77,9 +76,9 @@ import java.util.Locale
fun HomeScreen( fun HomeScreen(
navController: NavController, navController: NavController,
showNavigationHeader: Boolean = true, showNavigationHeader: Boolean = true,
viewModel: HomeViewModel = hiltViewModel(), navigationViewModel: NavigationViewModel,
viewModel: HomeViewModel,
) { ) {
val navigationViewModel: NavigationViewModel = hiltViewModel()
val navigationState by navigationViewModel.state.collectAsState() val navigationState by navigationViewModel.state.collectAsState()
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
var selectedNews by remember { mutableStateOf<NewsDto?>(null) } var selectedNews by remember { mutableStateOf<NewsDto?>(null) }
@@ -107,6 +106,13 @@ fun HomeScreen(
AppNavigationHeader( AppNavigationHeader(
selectedRoute = Destinations.Home.route, selectedRoute = Destinations.Home.route,
onNavigate = navController::navigate, onNavigate = navController::navigate,
onLogout = {
navigationViewModel.logout {
navController.navigate(Destinations.Home.route) {
launchSingleTop = true
}
}
},
navigationState = navigationState, navigationState = navigationState,
) )
} }

View File

@@ -49,7 +49,7 @@ fun PasswordResetScreen(
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
AuthFormPage( AuthFormPage(
title = "Passwort zurücksetzen", title = "Passwort zurücksetzen",
subtitle = "Geben Sie Ihre E-Mail-Adresse ein, um Ihr Passwort zurückzusetzen.", subtitle = "Geben Sie Ihre E-Mail-Adresse ein, um einen Reset-Link zu erhalten.",
onBack = { navController.navigate(Destinations.Login.route) }, onBack = { navController.navigate(Destinations.Login.route) },
showBackNavigation = showBackNavigation, showBackNavigation = showBackNavigation,
) { ) {
@@ -68,7 +68,7 @@ fun PasswordResetScreen(
TextButton(onClick = { navController.navigate(Destinations.Login.route) }, modifier = Modifier.fillMaxWidth()) { TextButton(onClick = { navController.navigate(Destinations.Login.route) }, modifier = Modifier.fillMaxWidth()) {
Text("Zurück zum Login") Text("Zurück zum Login")
} }
AuthNotice("Sie erhalten eine E-Mail mit einem temporären Passwort, sofern ein Konto vorhanden ist.") AuthNotice("Sie erhalten eine E-Mail mit einem Reset-Link, sofern ein Konto vorhanden ist. Ihr bisheriges Passwort bleibt bis zur Änderung gültig.")
} }
} }

View File

@@ -17,8 +17,10 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -27,6 +29,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -34,15 +37,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import de.harheimertc.data.SpielDto import de.harheimertc.data.SpielDto
import de.harheimertc.data.LeagueTableRowDto import de.harheimertc.data.LeagueTableRowDto
import de.harheimertc.repositories.Mannschaft import de.harheimertc.repositories.Mannschaft
import de.harheimertc.ui.components.LoadingState
import de.harheimertc.ui.navigation.Destinations import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.ui.theme.Accent100 import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent500 import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent700
import de.harheimertc.ui.theme.Accent900 import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100 import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600 import de.harheimertc.ui.theme.Primary600
@@ -64,13 +70,22 @@ fun MannschaftenScreen(
BackLink(navController, showBackNavigation) BackLink(navController, showBackNavigation)
Text("Unsere Mannschaften", style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 18.dp)) Text("Unsere Mannschaften", style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 18.dp))
Text("Unsere aktiven Mannschaften in der aktuellen Saison", color = Accent500, modifier = Modifier.padding(top = 8.dp)) Text("Unsere aktiven Mannschaften in der aktuellen Saison", color = Accent500, modifier = Modifier.padding(top = 8.dp))
if (state.seasons.isNotEmpty()) {
SeasonSelector(
seasons = state.seasons,
selectedSeason = state.selectedSeason,
onSeasonSelected = viewModel::selectSeason,
modifier = Modifier.padding(top = 14.dp),
)
}
} }
when { when {
state.seasonsLoading -> item { Loading() }
state.loading -> item { Loading() } state.loading -> item { Loading() }
state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) } state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) }
state.teams.isEmpty() -> item { Text("Keine Mannschaftsdaten geladen", color = Accent500) } state.teams.isEmpty() -> item { Text("Keine Mannschaftsdaten geladen", color = Accent500) }
else -> items(state.teams) { team -> else -> items(state.teams) { team ->
TeamCard(team) { navController.navigate(Destinations.MannschaftDetail.create(team.slug)) } TeamCard(team) { navController.navigate(Destinations.MannschaftDetail.create(team.slug, state.selectedSeason)) }
} }
} }
item { item {
@@ -88,6 +103,38 @@ fun MannschaftenScreen(
} }
} }
@Composable
private fun SeasonSelector(
seasons: List<de.harheimertc.data.SeasonDto>,
selectedSeason: String,
onSeasonSelected: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var open by remember { mutableStateOf(false) }
val selectedLabel = seasons.firstOrNull { it.slug == selectedSeason }?.label ?: selectedSeason
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Saison", color = Accent700, style = MaterialTheme.typography.labelSmall)
BoxWithConstraints {
OutlinedButton(onClick = { open = true }, modifier = Modifier.fillMaxWidth()) {
Text(selectedLabel.ifBlank { "-" }, modifier = Modifier.weight(1f), textAlign = TextAlign.Start)
Text("v")
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
seasons.forEach { season ->
DropdownMenuItem(
text = { Text(season.label.ifBlank { season.slug }) },
onClick = {
open = false
onSeasonSelected(season.slug)
},
)
}
}
}
}
}
@Composable @Composable
private fun TeamCard(team: Mannschaft, onOpen: () -> Unit) { private fun TeamCard(team: Mannschaft, onOpen: () -> Unit) {
Surface( Surface(
@@ -114,13 +161,14 @@ private fun TeamCard(team: Mannschaft, onOpen: () -> Unit) {
@Composable @Composable
fun MannschaftDetailScreen( fun MannschaftDetailScreen(
slug: String, slug: String,
season: String?,
navController: NavController, navController: NavController,
showBackNavigation: Boolean, showBackNavigation: Boolean,
viewModel: MannschaftDetailViewModel = hiltViewModel(), viewModel: MannschaftDetailViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
var selectedTab by rememberSaveable(slug) { mutableStateOf(DetailTab.Matches) } var selectedTab by rememberSaveable(slug, season) { mutableStateOf(DetailTab.Matches) }
LaunchedEffect(slug) { viewModel.load(slug) } LaunchedEffect(slug, season) { viewModel.load(slug, season) }
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)), modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 22.dp), contentPadding = PaddingValues(horizontal = 18.dp, vertical = 22.dp),
@@ -182,7 +230,7 @@ fun MannschaftDetailScreen(
} }
} }
} }
} ?: item { ErrorPanel(state.matchesError ?: "Mannschaft nicht gefunden.") { viewModel.load(slug) } } } ?: item { ErrorPanel(state.matchesError ?: "Mannschaft nicht gefunden.") { viewModel.load(slug, season) } }
} }
} }
} }
@@ -377,9 +425,7 @@ private fun BackLink(navController: NavController, visible: Boolean) {
@Composable @Composable
private fun Loading() { private fun Loading() {
Column(Modifier.fillMaxWidth().padding(30.dp), horizontalAlignment = Alignment.CenterHorizontally) { LoadingState("Mannschaftsdaten werden geladen...")
CircularProgressIndicator(color = Primary600)
}
} }
@Composable @Composable

View File

@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.SpielDto import de.harheimertc.data.SpielDto
import de.harheimertc.data.LeagueTableRowDto import de.harheimertc.data.LeagueTableRowDto
import de.harheimertc.data.SeasonDto
import de.harheimertc.repositories.Mannschaft import de.harheimertc.repositories.Mannschaft
import de.harheimertc.repositories.MannschaftenRepository import de.harheimertc.repositories.MannschaftenRepository
import de.harheimertc.repositories.SpielplanRepository import de.harheimertc.repositories.SpielplanRepository
@@ -17,6 +18,9 @@ data class MannschaftenUiState(
val loading: Boolean = true, val loading: Boolean = true,
val error: String? = null, val error: String? = null,
val teams: List<Mannschaft> = emptyList(), val teams: List<Mannschaft> = emptyList(),
val seasons: List<SeasonDto> = emptyList(),
val selectedSeason: String = "",
val seasonsLoading: Boolean = false,
) )
@HiltViewModel @HiltViewModel
@@ -27,17 +31,76 @@ class MannschaftenViewModel @Inject constructor(
val state: StateFlow<MannschaftenUiState> = _state val state: StateFlow<MannschaftenUiState> = _state
init { init {
load() loadSeasonsAndMannschaften()
} }
fun load() { fun load() {
viewModelScope.launch { viewModelScope.launch {
_state.value = MannschaftenUiState(loading = true) val season = _state.value.selectedSeason.ifBlank { null }
repository.fetchMannschaften() _state.value = _state.value.copy(loading = true, error = null)
.onSuccess { _state.value = MannschaftenUiState(loading = false, teams = it) } repository.fetchMannschaften(season)
.onFailure { _state.value = MannschaftenUiState(loading = false, error = "Mannschaften konnten nicht geladen werden.") } .onSuccess { teams -> _state.value = _state.value.copy(loading = false, teams = teams) }
.onFailure { _state.value = _state.value.copy(loading = false, error = "Mannschaften konnten nicht geladen werden.") }
} }
} }
fun selectSeason(season: String) {
if (season == _state.value.selectedSeason) return
_state.value = _state.value.copy(selectedSeason = season)
load()
}
private fun loadSeasonsAndMannschaften() {
viewModelScope.launch {
_state.value = _state.value.copy(seasonsLoading = true, error = null)
repository.fetchSeasons()
.onSuccess { response ->
val currentSeason = getCurrentSeasonSlug()
val seasons = response.seasons
.map { season -> SeasonDto(slug = season, label = formatSeasonLabel(season)) }
.ifEmpty {
val fallbackSeason = response.currentSeason.ifBlank { currentSeason }
listOf(SeasonDto(slug = fallbackSeason, label = formatSeasonLabel(fallbackSeason)))
}
val serverCurrentSeason = response.currentSeason.ifBlank { currentSeason }
val selectedSeason = when {
seasons.any { it.slug == currentSeason } -> currentSeason
seasons.any { it.slug == serverCurrentSeason } -> serverCurrentSeason
response.defaultSeason.isNotBlank() -> response.defaultSeason
seasons.isNotEmpty() -> seasons.first().slug
else -> currentSeason
}
_state.value = _state.value.copy(
seasonsLoading = false,
seasons = seasons,
selectedSeason = selectedSeason,
)
load()
}
.onFailure {
val currentSeason = getCurrentSeasonSlug()
_state.value = _state.value.copy(
seasonsLoading = false,
seasons = listOf(SeasonDto(slug = currentSeason, label = formatSeasonLabel(currentSeason))),
selectedSeason = currentSeason,
)
load()
}
}
}
private fun getCurrentSeasonSlug(): String {
val now = java.util.Calendar.getInstance()
val year = now.get(java.util.Calendar.YEAR)
val startYear = if (now.get(java.util.Calendar.MONTH) >= 6) year else year - 1
val endYear = startYear + 1
return "%02d--%02d".format(startYear % 100, endYear % 100)
}
private fun formatSeasonLabel(seasonSlug: String): String {
val match = Regex("^(\\d{2})--(\\d{2})$").matchEntire(seasonSlug.trim()) ?: return seasonSlug
return "20${match.groupValues[1]}/${match.groupValues[2]}"
}
} }
data class MannschaftDetailUiState( data class MannschaftDetailUiState(
@@ -58,35 +121,38 @@ class MannschaftDetailViewModel @Inject constructor(
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(MannschaftDetailUiState()) private val _state = MutableStateFlow(MannschaftDetailUiState())
val state: StateFlow<MannschaftDetailUiState> = _state val state: StateFlow<MannschaftDetailUiState> = _state
private var loadedSlug: String? = null private var loadedKey: String? = null
fun load(slug: String) { fun load(slug: String, season: String? = null) {
if (loadedSlug == slug) return val selectedSeason = season?.takeIf { it.isNotBlank() }
loadedSlug = slug val key = "$slug|${selectedSeason.orEmpty()}"
if (loadedKey == key) return
loadedKey = key
viewModelScope.launch { viewModelScope.launch {
_state.value = MannschaftDetailUiState(loading = true) _state.value = MannschaftDetailUiState(loading = true, season = selectedSeason)
val team = mannschaftenRepository.fetchMannschaften().getOrDefault(emptyList()).find { it.slug == slug } val team = mannschaftenRepository.fetchMannschaften(selectedSeason).getOrDefault(emptyList()).find { it.slug == slug }
if (team == null) { if (team == null) {
_state.value = MannschaftDetailUiState(loading = false, matchesError = "Mannschaft nicht gefunden.") _state.value = MannschaftDetailUiState(loading = false, matchesError = "Mannschaft nicht gefunden.")
return@launch return@launch
} }
spielplanRepository.fetchSpielplan() spielplanRepository.fetchSpielplan(selectedSeason)
.onSuccess { plan -> .onSuccess { plan ->
_state.value = MannschaftDetailUiState( _state.value = MannschaftDetailUiState(
loading = false, loading = false,
team = team, team = team,
matches = plan.data.filter { matchesTeam(it, team.mannschaft) }, matches = plan.data.filter { matchesTeam(it, team.mannschaft) },
season = plan.season, season = plan.season ?: selectedSeason,
) )
if (team.informationenLink.isNotBlank()) { if (team.informationenLink.isNotBlank()) {
loadTable(team, plan.season) loadTable(team, plan.season ?: selectedSeason)
} }
} }
.onFailure { .onFailure {
_state.value = MannschaftDetailUiState( _state.value = MannschaftDetailUiState(
loading = false, loading = false,
team = team, team = team,
matchesError = "Der aktuelle Spielplan konnte nicht geladen werden.", season = selectedSeason,
matchesError = "Der Spielplan konnte nicht geladen werden.",
) )
} }
} }

View File

@@ -14,7 +14,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -39,6 +38,7 @@ import androidx.navigation.NavController
import de.harheimertc.data.MemberDto import de.harheimertc.data.MemberDto
import de.harheimertc.data.NewsDto import de.harheimertc.data.NewsDto
import de.harheimertc.data.QttrRowDto import de.harheimertc.data.QttrRowDto
import de.harheimertc.ui.components.LoadingState
import de.harheimertc.ui.components.RichText import de.harheimertc.ui.components.RichText
import de.harheimertc.ui.theme.Accent100 import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent500 import de.harheimertc.ui.theme.Accent500
@@ -163,7 +163,7 @@ fun MembersScreen(
} }
when { when {
state.loading -> item { CircularProgressIndicator(color = Primary600) } state.loading -> item { LoadingState("Mitglieder werden geladen...") }
state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) } state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) }
display.isEmpty() -> item { Text("Keine Mitglieder gefunden.", color = Accent700) } display.isEmpty() -> item { Text("Keine Mitglieder gefunden.", color = Accent700) }
else -> if (viewMode == "table") { else -> if (viewMode == "table") {
@@ -200,7 +200,7 @@ fun MemberNewsScreen(
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
MemberAreaPage(navController, showBackNavigation, "News", "Neuigkeiten und Ankündigungen im Mitgliederbereich") { MemberAreaPage(navController, showBackNavigation, "News", "Neuigkeiten und Ankündigungen im Mitgliederbereich") {
when { when {
state.loading -> item { CircularProgressIndicator(color = Primary600) } state.loading -> item { LoadingState("News werden geladen...") }
state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) } state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) }
state.news.isEmpty() -> item { Text("Noch keine News vorhanden.", color = Accent700) } state.news.isEmpty() -> item { Text("Noch keine News vorhanden.", color = Accent700) }
else -> items(state.news.size) { index -> NewsCard(state.news[index]) } else -> items(state.news.size) { index -> NewsCard(state.news[index]) }
@@ -269,7 +269,7 @@ fun QttrScreen(
} }
} }
when { when {
state.loading -> item { CircularProgressIndicator(color = Primary600) } state.loading -> item { LoadingState("QTTR-Werte werden geladen...") }
state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) } state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) }
state.rows.isEmpty() -> item { Text("Keine QTTR-Werte gefunden.", color = Accent700) } state.rows.isEmpty() -> item { Text("Keine QTTR-Werte gefunden.", color = Accent700) }
else -> items(state.rows.size) { index -> QttrRowCard(state.rows[index], isOwnRow(state.rows[index].playerName, state.currentUserName)) } else -> items(state.rows.size) { index -> QttrRowCard(state.rows[index], isOwnRow(state.rows[index].playerName, state.currentUserName)) }

View File

@@ -3,6 +3,7 @@ package de.harheimertc.ui.screens.memberarea
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ConnectivityMonitor
import de.harheimertc.data.AuthStatusResponse import de.harheimertc.data.AuthStatusResponse
import de.harheimertc.data.MemberDto import de.harheimertc.data.MemberDto
import de.harheimertc.data.NewsDto import de.harheimertc.data.NewsDto
@@ -24,12 +25,22 @@ data class MembersUiState(
@HiltViewModel @HiltViewModel
class MembersViewModel @Inject constructor( class MembersViewModel @Inject constructor(
private val repository: MemberAreaRepository, private val repository: MemberAreaRepository,
private val connectivityMonitor: ConnectivityMonitor,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(MembersUiState()) private val _state = MutableStateFlow(MembersUiState())
val state: StateFlow<MembersUiState> = _state val state: StateFlow<MembersUiState> = _state
init { init {
load() load()
viewModelScope.launch {
var wasOnline: Boolean? = null
connectivityMonitor.online.collect { online ->
if (online && wasOnline == false) {
load()
}
wasOnline = online
}
}
} }
fun updateQuery(query: String) { fun updateQuery(query: String) {
@@ -87,12 +98,22 @@ data class MemberNewsUiState(
@HiltViewModel @HiltViewModel
class MemberNewsViewModel @Inject constructor( class MemberNewsViewModel @Inject constructor(
private val repository: MemberAreaRepository, private val repository: MemberAreaRepository,
private val connectivityMonitor: ConnectivityMonitor,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(MemberNewsUiState()) private val _state = MutableStateFlow(MemberNewsUiState())
val state: StateFlow<MemberNewsUiState> = _state val state: StateFlow<MemberNewsUiState> = _state
init { init {
load() load()
viewModelScope.launch {
var wasOnline: Boolean? = null
connectivityMonitor.online.collect { online ->
if (online && wasOnline == false) {
load()
}
wasOnline = online
}
}
} }
fun load() { fun load() {
@@ -118,12 +139,22 @@ data class QttrUiState(
class QttrViewModel @Inject constructor( class QttrViewModel @Inject constructor(
private val repository: MemberAreaRepository, private val repository: MemberAreaRepository,
private val loginRepository: LoginRepository, private val loginRepository: LoginRepository,
private val connectivityMonitor: ConnectivityMonitor,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(QttrUiState()) private val _state = MutableStateFlow(QttrUiState())
val state: StateFlow<QttrUiState> = _state val state: StateFlow<QttrUiState> = _state
init { init {
load() load()
viewModelScope.launch {
var wasOnline: Boolean? = null
connectivityMonitor.online.collect { online ->
if (online && wasOnline == false) {
load()
}
wasOnline = online
}
}
} }
fun load() { fun load() {

View File

@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -27,8 +26,11 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import de.harheimertc.BuildConfig
import de.harheimertc.data.BirthdayDto import de.harheimertc.data.BirthdayDto
import de.harheimertc.ui.components.LoadingState
import de.harheimertc.ui.navigation.Destinations import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.ui.navigation.NavigationUiState
import de.harheimertc.ui.theme.Accent100 import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent500 import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent700 import de.harheimertc.ui.theme.Accent700
@@ -40,6 +42,7 @@ import de.harheimertc.ui.theme.Primary600
fun MemberAreaScreen( fun MemberAreaScreen(
navController: NavController, navController: NavController,
showBackNavigation: Boolean, showBackNavigation: Boolean,
navigationState: NavigationUiState = NavigationUiState(),
viewModel: MemberAreaViewModel = hiltViewModel(), viewModel: MemberAreaViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
@@ -63,6 +66,12 @@ fun MemberAreaScreen(
MemberAreaCardGrid(navController) MemberAreaCardGrid(navController)
} }
if (navigationState.isAdmin) {
item {
ServerInfoCard()
}
}
item { item {
BirthdayCard( BirthdayCard(
birthdays = state.birthdays, birthdays = state.birthdays,
@@ -74,6 +83,16 @@ fun MemberAreaScreen(
} }
} }
@Composable
private fun ServerInfoCard() {
Surface(color = Primary100, shape = RoundedCornerShape(14.dp), shadowElevation = 2.dp) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Serververbindung", style = MaterialTheme.typography.titleLarge, color = Accent900)
Text(BuildConfig.API_BASE_URL.trimEnd('/'), color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
}
@Composable @Composable
private fun MemberAreaCardGrid(navController: NavController) { private fun MemberAreaCardGrid(navController: NavController) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
@@ -83,6 +102,12 @@ private fun MemberAreaCardGrid(navController: NavController) {
marker = "P", marker = "P",
onClick = { navController.navigate(Destinations.Profile.route) }, onClick = { navController.navigate(Destinations.Profile.route) },
) )
MemberAreaCard(
title = "Benachrichtigungen",
description = "Persönliche Hinweise im Android-System verwalten",
marker = "B",
onClick = { navController.navigate(Destinations.NotificationSettings.route) },
)
MemberAreaCard( MemberAreaCard(
title = "Mitglieder", title = "Mitglieder",
description = "Kontaktdaten der Vereinsmitglieder", description = "Kontaktdaten der Vereinsmitglieder",
@@ -158,8 +183,7 @@ private fun BirthdayCard(
when { when {
loading -> { loading -> {
CircularProgressIndicator(color = Primary600, modifier = Modifier.size(28.dp)) LoadingState("Geburtstage werden geladen...")
Text("Lade...", color = Accent500)
} }
error != null -> { error != null -> {
Text(error, color = MaterialTheme.colorScheme.error) Text(error, color = MaterialTheme.colorScheme.error)

View File

@@ -3,6 +3,7 @@ package de.harheimertc.ui.screens.memberarea
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ConnectivityMonitor
import de.harheimertc.data.BirthdayDto import de.harheimertc.data.BirthdayDto
import de.harheimertc.repositories.MemberAreaRepository import de.harheimertc.repositories.MemberAreaRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -19,12 +20,22 @@ data class MemberAreaUiState(
@HiltViewModel @HiltViewModel
class MemberAreaViewModel @Inject constructor( class MemberAreaViewModel @Inject constructor(
private val repository: MemberAreaRepository, private val repository: MemberAreaRepository,
private val connectivityMonitor: ConnectivityMonitor,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(MemberAreaUiState()) private val _state = MutableStateFlow(MemberAreaUiState())
val state: StateFlow<MemberAreaUiState> = _state val state: StateFlow<MemberAreaUiState> = _state
init { init {
loadBirthdays() loadBirthdays()
viewModelScope.launch {
var wasOnline: Boolean? = null
connectivityMonitor.online.collect { online ->
if (online && wasOnline == false) {
loadBirthdays()
}
wasOnline = online
}
}
} }
fun loadBirthdays() { fun loadBirthdays() {

View File

@@ -14,7 +14,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -32,6 +31,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import de.harheimertc.data.NewsletterGroupDto import de.harheimertc.data.NewsletterGroupDto
import de.harheimertc.ui.components.LoadingState
import de.harheimertc.ui.components.ValidatedTextField import de.harheimertc.ui.components.ValidatedTextField
import de.harheimertc.ui.navigation.Destinations import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.ui.theme.Accent100 import de.harheimertc.ui.theme.Accent100
@@ -93,8 +93,7 @@ fun NewsletterConfirmScreen(
NewsletterStatusPage(navController, showBackNavigation, "Newsletter bestätigen") { NewsletterStatusPage(navController, showBackNavigation, "Newsletter bestätigen") {
when { when {
state.loading -> { state.loading -> {
CircularProgressIndicator(color = Primary600) LoadingState("Newsletter-Anmeldung wird bestätigt...")
Text("Newsletter-Anmeldung wird bestätigt...", color = Accent700)
} }
state.error != null -> { state.error != null -> {
Text("Fehler", style = MaterialTheme.typography.titleLarge, color = Accent900) Text("Fehler", style = MaterialTheme.typography.titleLarge, color = Accent900)
@@ -154,7 +153,7 @@ private fun NewsletterFormScreen(
Text("Wählen Sie einen Newsletter und geben Sie Ihre E-Mail-Adresse ein.", color = Accent500, modifier = Modifier.padding(top = 8.dp)) Text("Wählen Sie einen Newsletter und geben Sie Ihre E-Mail-Adresse ein.", color = Accent500, modifier = Modifier.padding(top = 8.dp))
} }
if (state.loading) { if (state.loading) {
item { CircularProgressIndicator(color = Primary600) } item { LoadingState("Newsletter-Daten werden geladen...") }
} else { } else {
item { item {
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) { Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {

View File

@@ -0,0 +1,276 @@
package de.harheimertc.ui.screens.notifications
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import de.harheimertc.notifications.HarheimerNotifications
import de.harheimertc.repositories.Mannschaft
import de.harheimertc.repositories.NotificationPreferences
import de.harheimertc.ui.components.LoadingState
import de.harheimertc.ui.navigation.NavigationUiState
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent700
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
private val notificationTimes = (6..22).flatMap { hour ->
listOf("%02d:00".format(hour), "%02d:30".format(hour))
}.dropLast(1)
@Composable
fun NotificationSettingsScreen(
navController: NavController,
showBackNavigation: Boolean,
navigationState: NavigationUiState,
viewModel: NotificationSettingsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
var hasPermission by remember { mutableStateOf(HarheimerNotifications.hasNotificationPermission(context)) }
val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
hasPermission = granted || HarheimerNotifications.hasNotificationPermission(context)
}
val isBoard = "vorstand" in navigationState.roles
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item {
if (showBackNavigation) {
TextButton(onClick = { navController.popBackStack() }) {
Text("< Intern", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
Text("Benachrichtigungen", style = MaterialTheme.typography.displayLarge, color = Accent900)
Text("Persönliche Android-Benachrichtigungen verwalten.", color = Accent500, modifier = Modifier.padding(top = 8.dp))
}
item {
NotificationCard("Android-Berechtigung") {
val permissionText = if (hasPermission) {
"Benachrichtigungen sind im Android-System erlaubt."
} else {
"Benachrichtigungen sind im Android-System noch nicht erlaubt."
}
Text(permissionText, color = if (hasPermission) Color(0xFF166534) else Accent700)
if (!hasPermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Button(
onClick = { permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) },
modifier = Modifier.fillMaxWidth(),
) {
Text("Berechtigung anfordern")
}
}
}
}
if (state.loading) {
item { LoadingState("Benachrichtigungseinstellungen werden geladen...") }
} else {
item {
NotificationCard("Benachrichtigungszeit") {
Text("Automatische Benachrichtigungen werden zu dieser Uhrzeit ausgelöst.", color = Accent700)
TimeSelection(state.settings.notificationTime) { selectedTime ->
viewModel.update(state.settings.copy(notificationTime = selectedTime))
}
}
}
item {
NotificationCard("News") {
ToggleRow("Neue News", state.settings.newNews) {
viewModel.update(state.settings.copy(newNews = it))
}
}
}
item {
NotificationCard("Termine") {
ToggleRow("Neue Termine", state.settings.newEvents) {
viewModel.update(state.settings.copy(newEvents = it))
}
ToggleRow("Termine von heute", state.settings.eventsToday) {
viewModel.update(state.settings.copy(eventsToday = it))
}
ToggleRow("Termine von morgen", state.settings.eventsTomorrow) {
viewModel.update(state.settings.copy(eventsTomorrow = it))
}
}
}
item {
NotificationCard("Punktspiele") {
ToggleRow("Punktspiele der eigenen Mannschaft", state.settings.ownTeamMatches) {
viewModel.update(state.settings.copy(ownTeamMatches = it))
}
Text("Die eigene Mannschaft wird aus dem Namen und der Mannschaftszusammensetzung ermittelt.", color = Accent700)
ToggleRow("Punktspiele aller Mannschaften", state.settings.allTeamMatches) {
viewModel.update(state.settings.copy(allTeamMatches = it))
}
TeamSelection(
teams = state.teams,
seasons = state.seasons,
selectedSeason = state.settings.selectedTeamSeason,
settings = state.settings,
onSelectSeason = viewModel::selectSeason,
onToggleTeam = viewModel::toggleTeam,
)
}
}
item {
NotificationCard("Mitglieder") {
ToggleRow("Geburtstage", state.settings.birthdays) {
viewModel.update(state.settings.copy(birthdays = it))
}
}
}
if (isBoard) {
item {
NotificationCard("Vorstand") {
ToggleRow("Neue Kontaktanfrage", state.settings.newContactRequest) {
viewModel.update(state.settings.copy(newContactRequest = it))
}
ToggleRow("Neue Benutzerregistrierung", state.settings.newUserRegistration) {
viewModel.update(state.settings.copy(newUserRegistration = it))
}
}
}
}
}
state.saveError?.let { message ->
item { Text(message, color = MaterialTheme.colorScheme.error) }
}
state.error?.let { message ->
item {
Text(message, color = MaterialTheme.colorScheme.error)
TextButton(onClick = viewModel::load) { Text("Erneut laden") }
}
}
}
}
@Composable
private fun NotificationCard(title: String, content: @Composable ColumnScope.() -> Unit) {
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 2.dp) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
content()
}
}
}
@Composable
private fun ToggleRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
Text(label, color = Accent900, modifier = Modifier.weight(1f))
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
@Composable
private fun TimeSelection(selectedTime: String, onSelectTime: (String) -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
notificationTimes.forEach { time ->
if (time == selectedTime) {
Button(onClick = { onSelectTime(time) }) { Text(time) }
} else {
OutlinedButton(onClick = { onSelectTime(time) }) { Text(time) }
}
}
}
}
@Composable
private fun TeamSelection(
teams: List<Mannschaft>,
seasons: List<String>,
selectedSeason: String?,
settings: NotificationPreferences,
onSelectSeason: (String) -> Unit,
onToggleTeam: (String, Boolean) -> Unit,
) {
Surface(color = Primary100, shape = RoundedCornerShape(10.dp)) {
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Ausgewählte Mannschaften", color = Accent900, fontWeight = FontWeight.SemiBold)
Text("Zusätzlich einzelne Mannschaften abonnieren.", color = Accent700)
if (seasons.isNotEmpty()) {
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
seasons.forEach { season ->
val selected = season == selectedSeason
if (selected) {
Button(onClick = { onSelectSeason(season) }) { Text(season) }
} else {
OutlinedButton(onClick = { onSelectSeason(season) }) { Text(season) }
}
}
}
}
if (teams.isEmpty()) {
Text("Keine Mannschaften verfügbar.", color = Accent700)
} else {
teams.forEach { team ->
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Checkbox(
checked = team.slug in settings.selectedTeamSlugs,
onCheckedChange = { onToggleTeam(team.slug, it) },
)
Text(team.mannschaft, color = Accent900)
}
}
}
}
}
}

View File

@@ -0,0 +1,110 @@
package de.harheimertc.ui.screens.notifications
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.repositories.Mannschaft
import de.harheimertc.repositories.MannschaftenRepository
import de.harheimertc.repositories.NotificationPreferences
import de.harheimertc.repositories.NotificationPreferencesRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class NotificationSettingsUiState(
val loading: Boolean = true,
val settings: NotificationPreferences = NotificationPreferences(),
val teams: List<Mannschaft> = emptyList(),
val seasons: List<String> = emptyList(),
val error: String? = null,
val saveError: String? = null,
)
@HiltViewModel
class NotificationSettingsViewModel @Inject constructor(
private val preferencesRepository: NotificationPreferencesRepository,
private val mannschaftenRepository: MannschaftenRepository,
) : ViewModel() {
private val _state = MutableStateFlow(NotificationSettingsUiState())
val state: StateFlow<NotificationSettingsUiState> = _state
init {
load()
}
fun load() {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null, saveError = null)
val storedSettings = preferencesRepository.loadRemote().getOrElse { preferencesRepository.loadLocal() }
val seasonsResponse = mannschaftenRepository.fetchSeasons().getOrNull()
val seasons = seasonsResponse?.seasons.orEmpty()
val selectedSeason = storedSettings.selectedTeamSeason
?: seasonsResponse?.defaultSeason?.takeIf { it.isNotBlank() }
?: seasons.firstOrNull()
loadTeams(storedSettings.copy(selectedTeamSeason = selectedSeason), seasons)
}
}
fun selectSeason(season: String) {
val current = _state.value.settings
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null, saveError = null)
loadTeams(current.copy(selectedTeamSeason = season), _state.value.seasons, syncRemote = true)
}
}
fun update(settings: NotificationPreferences) {
preferencesRepository.saveLocal(settings)
_state.value = _state.value.copy(settings = settings, saveError = null)
viewModelScope.launch {
preferencesRepository.saveRemote(settings)
.onSuccess { saved -> _state.value = _state.value.copy(settings = saved, saveError = null) }
.onFailure { error ->
_state.value = _state.value.copy(saveError = error.message ?: "Benachrichtigungseinstellungen konnten nicht gespeichert werden.")
}
}
}
fun toggleTeam(slug: String, selected: Boolean) {
val current = _state.value.settings
val nextTeams = if (selected) {
current.selectedTeamSlugs + slug
} else {
current.selectedTeamSlugs - slug
}
update(current.copy(selectedTeamSlugs = nextTeams))
}
private suspend fun loadTeams(settings: NotificationPreferences, seasons: List<String>, syncRemote: Boolean = false) {
mannschaftenRepository.fetchMannschaften(settings.selectedTeamSeason)
.onSuccess { teams ->
val knownSlugs = teams.map { it.slug }.toSet()
val nextSettings = settings.copy(selectedTeamSlugs = settings.selectedTeamSlugs.intersect(knownSlugs))
preferencesRepository.saveLocal(nextSettings)
val saveError = if (syncRemote) {
preferencesRepository.saveRemote(nextSettings).exceptionOrNull()?.message
} else null
_state.value = NotificationSettingsUiState(
loading = false,
settings = nextSettings,
teams = teams,
seasons = seasons,
saveError = saveError,
)
}
.onFailure { error ->
preferencesRepository.saveLocal(settings)
val saveError = if (syncRemote) {
preferencesRepository.saveRemote(settings).exceptionOrNull()?.message
} else null
_state.value = NotificationSettingsUiState(
loading = false,
settings = settings,
seasons = seasons,
error = error.message ?: "Mannschaften konnten nicht geladen werden.",
saveError = saveError,
)
}
}
}

View File

@@ -38,6 +38,7 @@ import androidx.navigation.NavController
import de.harheimertc.ui.theme.Accent500 import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent900 import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.components.ValidatedTextField import de.harheimertc.ui.components.ValidatedTextField
import de.harheimertc.ui.components.LoadingState
import de.harheimertc.ui.theme.Primary600 import de.harheimertc.ui.theme.Primary600
@Composable @Composable
@@ -67,9 +68,7 @@ fun ProfileScreen(
if (state.loading) { if (state.loading) {
item { item {
Column(Modifier.fillMaxWidth().padding(28.dp), horizontalAlignment = Alignment.CenterHorizontally) { LoadingState("Profil wird geladen...")
CircularProgressIndicator(color = Primary600)
}
} }
} else { } else {
item { item {

View File

@@ -15,7 +15,6 @@ import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -27,6 +26,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
import de.harheimertc.BuildConfig import de.harheimertc.BuildConfig
import de.harheimertc.ui.components.LoadingState
import de.harheimertc.ui.components.RichText import de.harheimertc.ui.components.RichText
import de.harheimertc.ui.theme.Accent500 import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent900 import de.harheimertc.ui.theme.Accent900
@@ -68,9 +68,7 @@ internal fun PublicCard(title: String? = null, content: @Composable () -> Unit)
@Composable @Composable
internal fun PublicLoading() { internal fun PublicLoading() {
Column(Modifier.fillMaxWidth().padding(28.dp), horizontalAlignment = Alignment.CenterHorizontally) { LoadingState()
CircularProgressIndicator(color = Primary600)
}
} }
@Composable @Composable

View File

@@ -18,7 +18,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -43,6 +42,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import de.harheimertc.data.SeasonDto import de.harheimertc.data.SeasonDto
import de.harheimertc.data.SpielDto import de.harheimertc.data.SpielDto
import de.harheimertc.ui.components.LoadingState
import de.harheimertc.ui.theme.Accent100 import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent200 import de.harheimertc.ui.theme.Accent200
import de.harheimertc.ui.theme.Accent500 import de.harheimertc.ui.theme.Accent500
@@ -228,10 +228,7 @@ private fun MatchRow(game: SpielDto) {
@Composable @Composable
private fun LoadingPlan() { private fun LoadingPlan() {
Column(Modifier.fillMaxWidth().padding(40.dp), horizontalAlignment = Alignment.CenterHorizontally) { LoadingState("Spielpläne werden geladen...")
CircularProgressIndicator(color = Primary600)
Text("Spielpläne werden geladen...", color = Accent500, modifier = Modifier.padding(top = 12.dp))
}
} }
@Composable @Composable

View File

@@ -15,7 +15,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -32,6 +31,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import de.harheimertc.data.TerminDto import de.harheimertc.data.TerminDto
import de.harheimertc.ui.components.LoadingState
import de.harheimertc.ui.theme.Accent500 import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent700 import de.harheimertc.ui.theme.Accent700
import de.harheimertc.ui.theme.Accent900 import de.harheimertc.ui.theme.Accent900
@@ -125,10 +125,7 @@ private fun TerminCard(termin: TerminDto) {
@Composable @Composable
private fun LoadingPanel() { private fun LoadingPanel() {
Column(Modifier.fillMaxWidth().padding(vertical = 38.dp), horizontalAlignment = Alignment.CenterHorizontally) { LoadingState("Termine werden geladen...")
CircularProgressIndicator(color = Primary600)
Text("Termine werden geladen...", color = Accent500, modifier = Modifier.padding(top = 12.dp))
}
} }
@Composable @Composable

View File

@@ -13,7 +13,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -32,6 +31,7 @@ import androidx.navigation.NavController
import de.harheimertc.data.TrainingTimeDto import de.harheimertc.data.TrainingTimeDto
import de.harheimertc.data.TrainerDto import de.harheimertc.data.TrainerDto
import de.harheimertc.ui.navigation.Destinations import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.ui.components.LoadingState
import de.harheimertc.ui.theme.Accent500 import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent700 import de.harheimertc.ui.theme.Accent700
import de.harheimertc.ui.theme.Accent900 import de.harheimertc.ui.theme.Accent900
@@ -193,9 +193,7 @@ private fun TrainerCard(trainer: TrainerDto) {
@Composable @Composable
private fun Loading() { private fun Loading() {
Column(Modifier.fillMaxWidth().padding(30.dp), horizontalAlignment = Alignment.CenterHorizontally) { LoadingState("Trainingsdaten werden geladen...")
CircularProgressIndicator(color = Primary600)
}
} }
@Composable @Composable

View File

@@ -6,6 +6,7 @@ plugins {
id("com.google.devtools.ksp") version "2.3.7" apply false id("com.google.devtools.ksp") version "2.3.7" apply false
id("org.jetbrains.kotlin.kapt") version "2.3.21" apply false id("org.jetbrains.kotlin.kapt") version "2.3.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false
id("com.google.gms.google-services") version "4.4.4" apply false
} }
buildscript { buildscript {

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=19 ANDROID_VERSION_CODE=25
ANDROID_VERSION_NAME=0.9.14 ANDROID_VERSION_NAME=0.9.20
# 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

View File

@@ -78,53 +78,62 @@
</div> </div>
</div> </div>
<!-- News Modal --> <Teleport to="body">
<div <Transition name="news-modal">
v-if="selectedNews" <div
class="fixed inset-0 z-50 bg-black bg-opacity-50 flex items-center justify-center p-4" v-if="selectedNews"
@click.self="closeNewsModal" class="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950/65 px-4 py-6 sm:px-6"
> role="dialog"
<div class="bg-white rounded-xl shadow-2xl max-w-4xl w-full max-h-[90vh] flex flex-col"> aria-modal="true"
<!-- Modal Header --> :aria-labelledby="modalTitleId"
<div class="flex items-center justify-between p-6 border-b border-gray-200"> @click.self="closeNewsModal"
<div class="flex-1"> >
<div class="flex items-center text-sm text-gray-500 mb-2"> <article class="w-full max-w-3xl max-h-[min(44rem,calc(100vh-3rem))] overflow-hidden rounded-lg bg-white shadow-2xl ring-1 ring-black/10 flex flex-col">
<Calendar <header class="flex items-start gap-4 border-b border-gray-200 bg-white px-5 py-4 sm:px-7 sm:py-6">
:size="16" <div class="min-w-0 flex-1">
class="mr-2" <div class="mb-3 inline-flex items-center gap-2 rounded-full bg-primary-50 px-3 py-1 text-sm font-medium text-primary-800">
/> <Calendar :size="15" />
{{ formatDate(selectedNews.created) }} <time :datetime="selectedNews.created">
</div> {{ formatDate(selectedNews.created) }}
<h2 class="text-2xl font-display font-bold text-gray-900"> </time>
{{ selectedNews.title }} </div>
</h2> <h2
</div> :id="modalTitleId"
<button class="text-2xl sm:text-3xl font-display font-bold leading-tight text-gray-950 break-words"
class="ml-4 p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors" >
@click="closeNewsModal" {{ selectedNews.title }}
> </h2>
<X :size="24" /> </div>
</button> <button
</div> type="button"
class="shrink-0 rounded-md border border-gray-200 bg-white p-2 text-gray-500 shadow-sm transition-colors hover:bg-gray-50 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary-500"
aria-label="News schließen"
@click="closeNewsModal"
>
<X :size="20" />
</button>
</header>
<!-- Modal Content (scrollable) --> <div class="overflow-y-auto px-5 py-5 sm:px-7 sm:py-6">
<div class="p-6 overflow-y-auto flex-1"> <div class="news-modal-content text-base leading-7 text-gray-800 sm:text-lg sm:leading-8">
<div class="prose max-w-none text-gray-700 whitespace-pre-wrap"> {{ selectedNews.content }}
{{ selectedNews.content }} </div>
</div> </div>
</article>
</div> </div>
</div> </Transition>
</div> </Teleport>
</section> </section>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { Calendar, X } from 'lucide-vue-next' import { Calendar, X } from 'lucide-vue-next'
const news = ref([]) const news = ref([])
const selectedNews = ref(null) const selectedNews = ref(null)
const isLoading = ref(true) const isLoading = ref(true)
const modalTitleId = 'public-news-modal-title'
const loadNews = async () => { const loadNews = async () => {
try { try {
@@ -164,19 +173,30 @@ const getGridClass = () => {
const openNewsModal = (item) => { const openNewsModal = (item) => {
selectedNews.value = item selectedNews.value = item
// Verhindere Scrollen im Hintergrund
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden'
document.addEventListener('keydown', handleModalKeydown)
} }
const closeNewsModal = () => { const closeNewsModal = () => {
selectedNews.value = null selectedNews.value = null
// Erlaube Scrollen wieder
document.body.style.overflow = '' document.body.style.overflow = ''
document.removeEventListener('keydown', handleModalKeydown)
}
const handleModalKeydown = (event) => {
if (event.key === 'Escape') {
closeNewsModal()
}
} }
onMounted(() => { onMounted(() => {
loadNews() loadNews()
}) })
onUnmounted(() => {
document.body.style.overflow = ''
document.removeEventListener('keydown', handleModalKeydown)
})
</script> </script>
<style scoped> <style scoped>
@@ -186,5 +206,20 @@ onMounted(() => {
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
.news-modal-content {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.news-modal-enter-active,
.news-modal-leave-active {
transition: opacity 160ms ease;
}
.news-modal-enter-from,
.news-modal-leave-to {
opacity: 0;
}
</style> </style>

View File

@@ -279,9 +279,25 @@ use_project_node
ensure_node_version ensure_node_version
install_dependencies_if_needed install_dependencies_if_needed
# 4. Remove old build (but keep data!) # 4. Stop running apps before replacing build artifacts
echo "" echo ""
echo "4. Cleaning build artifacts..." echo "4. Stopping PM2 before replacing build artifacts..."
if command -v pm2 >/dev/null 2>&1; then
for instance_name in harheimertc harheimertc-3102; do
if pm2 describe "$instance_name" >/dev/null 2>&1; then
pm2 stop "$instance_name" || true
echo "$instance_name gestoppt"
else
echo " PM2-Prozess $instance_name läuft nicht"
fi
done
else
echo " PM2 ist nicht verfügbar"
fi
# 5. Remove old build (but keep data!)
echo ""
echo "5. Cleaning build artifacts..."
# Sicherstellen, dass .output vollständig gelöscht wird # Sicherstellen, dass .output vollständig gelöscht wird
if [ -d ".output" ]; then if [ -d ".output" ]; then
echo " Removing .output directory..." echo " Removing .output directory..."

View File

@@ -285,9 +285,19 @@ use_project_node
ensure_node_version ensure_node_version
install_dependencies_if_needed install_dependencies_if_needed
# 4. Remove old build (but keep data!) # 4. Stop running app before replacing build artifacts
echo "" echo ""
echo "4. Cleaning build artifacts..." echo "4. Stopping PM2 before replacing build artifacts..."
if command -v pm2 >/dev/null 2>&1 && pm2 describe harheimertc.test >/dev/null 2>&1; then
pm2 stop harheimertc.test || true
echo " ✓ harheimertc.test gestoppt"
else
echo " PM2-Prozess harheimertc.test läuft nicht oder PM2 ist nicht verfügbar"
fi
# 5. Remove old build (but keep data!)
echo ""
echo "5. Cleaning build artifacts..."
# Sicherstellen, dass .output vollständig gelöscht wird # Sicherstellen, dass .output vollständig gelöscht wird
if [ -d ".output" ]; then if [ -d ".output" ]; then
echo " Removing .output directory..." echo " Removing .output directory..."

67
google-services.json Normal file
View File

@@ -0,0 +1,67 @@
{
"project_info": {
"project_number": "174247719758",
"project_id": "harheimer-tc",
"storage_bucket": "harheimer-tc.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:174247719758:android:04240f5a6ecc06b8eba41f",
"android_client_info": {
"package_name": "de.harheimertc"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBgBvGVEtaTmK6LCUwhJ5BUJJTq0FrN6yA"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:174247719758:android:852797b868a6413feba41f",
"android_client_info": {
"package_name": "de.harheimertc.local"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBgBvGVEtaTmK6LCUwhJ5BUJJTq0FrN6yA"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:174247719758:android:0663d7ca236b5db2eba41f",
"android_client_info": {
"package_name": "de.harheimertc.test"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBgBvGVEtaTmK6LCUwhJ5BUJJTq0FrN6yA"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

View File

@@ -7,6 +7,8 @@ try {
} }
// Helper function to create env object // Helper function to create env object
const DEFAULT_GOOGLE_APPLICATION_CREDENTIALS = '/var/www/harheimertc/server/data/harheimer-tc-firebase-adminsdk-fbsvc-18b66a2971.json'
function createEnv(port) { function createEnv(port) {
return { return {
NODE_ENV: 'production', NODE_ENV: 'production',
@@ -35,7 +37,10 @@ function createEnv(port) {
WEBAUTHN_ORIGIN: process.env.WEBAUTHN_ORIGIN, WEBAUTHN_ORIGIN: process.env.WEBAUTHN_ORIGIN,
WEBAUTHN_RP_ID: process.env.WEBAUTHN_RP_ID, WEBAUTHN_RP_ID: process.env.WEBAUTHN_RP_ID,
WEBAUTHN_RP_NAME: process.env.WEBAUTHN_RP_NAME, WEBAUTHN_RP_NAME: process.env.WEBAUTHN_RP_NAME,
WEBAUTHN_REQUIRE_UV: process.env.WEBAUTHN_REQUIRE_UV WEBAUTHN_REQUIRE_UV: process.env.WEBAUTHN_REQUIRE_UV,
FCM_SERVICE_ACCOUNT_JSON: process.env.FCM_SERVICE_ACCOUNT_JSON,
GOOGLE_APPLICATION_CREDENTIALS: process.env.GOOGLE_APPLICATION_CREDENTIALS || DEFAULT_GOOGLE_APPLICATION_CREDENTIALS,
FCM_PROJECT_ID: process.env.FCM_PROJECT_ID
} }
} }

View File

@@ -8,6 +8,8 @@ try {
} }
// Helper function to create env object // Helper function to create env object
const DEFAULT_GOOGLE_APPLICATION_CREDENTIALS = '/var/www/harheimertc.test/server/data/harheimer-tc-firebase-adminsdk-fbsvc-18b66a2971.json'
function createEnv(port) { function createEnv(port) {
return { return {
NODE_ENV: process.env.NODE_ENV || 'development', NODE_ENV: process.env.NODE_ENV || 'development',
@@ -37,7 +39,10 @@ function createEnv(port) {
WEBAUTHN_ORIGIN: process.env.WEBAUTHN_ORIGIN, WEBAUTHN_ORIGIN: process.env.WEBAUTHN_ORIGIN,
WEBAUTHN_RP_ID: process.env.WEBAUTHN_RP_ID, WEBAUTHN_RP_ID: process.env.WEBAUTHN_RP_ID,
WEBAUTHN_RP_NAME: process.env.WEBAUTHN_RP_NAME, WEBAUTHN_RP_NAME: process.env.WEBAUTHN_RP_NAME,
WEBAUTHN_REQUIRE_UV: process.env.WEBAUTHN_REQUIRE_UV WEBAUTHN_REQUIRE_UV: process.env.WEBAUTHN_REQUIRE_UV,
FCM_SERVICE_ACCOUNT_JSON: process.env.FCM_SERVICE_ACCOUNT_JSON,
GOOGLE_APPLICATION_CREDENTIALS: process.env.GOOGLE_APPLICATION_CREDENTIALS || DEFAULT_GOOGLE_APPLICATION_CREDENTIALS,
FCM_PROJECT_ID: process.env.FCM_PROJECT_ID
} }
} }

176
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "harheimertc-website", "name": "harheimertc-website",
"version": "1.7.0", "version": "1.8.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "harheimertc-website", "name": "harheimertc-website",
"version": "1.7.0", "version": "1.8.1",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@pinia/nuxt": "^0.11.2", "@pinia/nuxt": "^0.11.2",
@@ -37,7 +37,7 @@
"postcss": "^8.5.12", "postcss": "^8.5.12",
"supertest": "^7.1.0", "supertest": "^7.1.0",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"vitest": "^4.0.16", "vitest": "^4.1.8",
"vue-eslint-parser": "^10.2.0" "vue-eslint-parser": "^10.2.0"
}, },
"engines": { "engines": {
@@ -5418,31 +5418,31 @@
} }
}, },
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "4.0.16", "version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
"integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.0.0", "@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/spy": "4.0.16", "@vitest/spy": "4.1.8",
"@vitest/utils": "4.0.16", "@vitest/utils": "4.1.8",
"chai": "^6.2.1", "chai": "^6.2.2",
"tinyrainbow": "^3.0.3" "tinyrainbow": "^3.1.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/@vitest/mocker": { "node_modules/@vitest/mocker": {
"version": "4.0.16", "version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
"integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/spy": "4.0.16", "@vitest/spy": "4.1.8",
"estree-walker": "^3.0.3", "estree-walker": "^3.0.3",
"magic-string": "^0.30.21" "magic-string": "^0.30.21"
}, },
@@ -5451,7 +5451,7 @@
}, },
"peerDependencies": { "peerDependencies": {
"msw": "^2.4.9", "msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0-0" "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"msw": { "msw": {
@@ -5463,26 +5463,26 @@
} }
}, },
"node_modules/@vitest/pretty-format": { "node_modules/@vitest/pretty-format": {
"version": "4.0.16", "version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
"integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tinyrainbow": "^3.0.3" "tinyrainbow": "^3.1.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/@vitest/runner": { "node_modules/@vitest/runner": {
"version": "4.0.16", "version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz",
"integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/utils": "4.0.16", "@vitest/utils": "4.1.8",
"pathe": "^2.0.3" "pathe": "^2.0.3"
}, },
"funding": { "funding": {
@@ -5490,13 +5490,14 @@
} }
}, },
"node_modules/@vitest/snapshot": { "node_modules/@vitest/snapshot": {
"version": "4.0.16", "version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz",
"integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "4.0.16", "@vitest/pretty-format": "4.1.8",
"@vitest/utils": "4.1.8",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
"pathe": "^2.0.3" "pathe": "^2.0.3"
}, },
@@ -5505,9 +5506,9 @@
} }
}, },
"node_modules/@vitest/spy": { "node_modules/@vitest/spy": {
"version": "4.0.16", "version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz",
"integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@@ -5515,14 +5516,15 @@
} }
}, },
"node_modules/@vitest/utils": { "node_modules/@vitest/utils": {
"version": "4.0.16", "version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
"integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "4.0.16", "@vitest/pretty-format": "4.1.8",
"tinyrainbow": "^3.0.3" "convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
@@ -6666,9 +6668,9 @@
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/chai": { "node_modules/chai": {
"version": "6.2.1", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -7733,10 +7735,9 @@
} }
}, },
"node_modules/es-module-lexer": { "node_modules/es-module-lexer": {
"version": "1.7.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/es-object-atoms": { "node_modules/es-object-atoms": {
@@ -8164,9 +8165,9 @@
} }
}, },
"node_modules/expect-type": { "node_modules/expect-type": {
"version": "1.2.2", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
@@ -9014,12 +9015,6 @@
"unplugin-utils": "^0.3.1" "unplugin-utils": "^0.3.1"
} }
}, },
"node_modules/impound/node_modules/es-module-lexer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
"license": "MIT"
},
"node_modules/impound/node_modules/unplugin": { "node_modules/impound/node_modules/unplugin": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz",
@@ -12985,9 +12980,9 @@
} }
}, },
"node_modules/shell-quote": { "node_modules/shell-quote": {
"version": "1.8.3", "version": "1.8.4",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -13961,9 +13956,9 @@
} }
}, },
"node_modules/tinyrainbow": { "node_modules/tinyrainbow": {
"version": "3.0.3", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -14624,12 +14619,6 @@
"url": "https://opencollective.com/antfu" "url": "https://opencollective.com/antfu"
} }
}, },
"node_modules/vite-node/node_modules/es-module-lexer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
"license": "MIT"
},
"node_modules/vite-plugin-inspect": { "node_modules/vite-plugin-inspect": {
"version": "11.3.3", "version": "11.3.3",
"resolved": "https://registry.npmjs.org/vite-plugin-inspect/-/vite-plugin-inspect-11.3.3.tgz", "resolved": "https://registry.npmjs.org/vite-plugin-inspect/-/vite-plugin-inspect-11.3.3.tgz",
@@ -15172,31 +15161,31 @@
} }
}, },
"node_modules/vitest": { "node_modules/vitest": {
"version": "4.0.16", "version": "4.1.8",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/expect": "4.0.16", "@vitest/expect": "4.1.8",
"@vitest/mocker": "4.0.16", "@vitest/mocker": "4.1.8",
"@vitest/pretty-format": "4.0.16", "@vitest/pretty-format": "4.1.8",
"@vitest/runner": "4.0.16", "@vitest/runner": "4.1.8",
"@vitest/snapshot": "4.0.16", "@vitest/snapshot": "4.1.8",
"@vitest/spy": "4.0.16", "@vitest/spy": "4.1.8",
"@vitest/utils": "4.0.16", "@vitest/utils": "4.1.8",
"es-module-lexer": "^1.7.0", "es-module-lexer": "^2.0.0",
"expect-type": "^1.2.2", "expect-type": "^1.3.0",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
"obug": "^2.1.1", "obug": "^2.1.1",
"pathe": "^2.0.3", "pathe": "^2.0.3",
"picomatch": "^4.0.3", "picomatch": "^4.0.3",
"std-env": "^3.10.0", "std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0", "tinybench": "^2.9.0",
"tinyexec": "^1.0.2", "tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15", "tinyglobby": "^0.2.15",
"tinyrainbow": "^3.0.3", "tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0" "why-is-node-running": "^2.3.0"
}, },
"bin": { "bin": {
@@ -15212,12 +15201,15 @@
"@edge-runtime/vm": "*", "@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.0.16", "@vitest/browser-playwright": "4.1.8",
"@vitest/browser-preview": "4.0.16", "@vitest/browser-preview": "4.1.8",
"@vitest/browser-webdriverio": "4.0.16", "@vitest/browser-webdriverio": "4.1.8",
"@vitest/ui": "4.0.16", "@vitest/coverage-istanbul": "4.1.8",
"@vitest/coverage-v8": "4.1.8",
"@vitest/ui": "4.1.8",
"happy-dom": "*", "happy-dom": "*",
"jsdom": "*" "jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@edge-runtime/vm": { "@edge-runtime/vm": {
@@ -15238,6 +15230,12 @@
"@vitest/browser-webdriverio": { "@vitest/browser-webdriverio": {
"optional": true "optional": true
}, },
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": { "@vitest/ui": {
"optional": true "optional": true
}, },
@@ -15246,9 +15244,19 @@
}, },
"jsdom": { "jsdom": {
"optional": true "optional": true
},
"vite": {
"optional": false
} }
} }
}, },
"node_modules/vitest/node_modules/std-env": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/vscode-uri": { "node_modules/vscode-uri": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "harheimertc-website", "name": "harheimertc-website",
"version": "1.8.0", "version": "1.8.1",
"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",
@@ -16,9 +16,12 @@
"start": "nuxt start --port 3100", "start": "nuxt start --port 3100",
"postinstall": "nuxt prepare", "postinstall": "nuxt prepare",
"test": "vitest run", "test": "vitest run",
"test:data-rotation": "vitest run tests/data-file-rotation.spec.ts",
"check-security": "node scripts/verify-no-public-writes.js", "check-security": "node scripts/verify-no-public-writes.js",
"smoke-local": "BASE_URL=http://127.0.0.1:3100 node scripts/smoke-tests.js", "smoke-local": "BASE_URL=http://127.0.0.1:3100 node scripts/smoke-tests.js",
"sync-public-data": "node scripts/sync-public-data.js", "sync-public-data": "node scripts/sync-public-data.js",
"data-backups:list": "node scripts/data-backup-restore.js list",
"data-backups:restore": "node scripts/data-backup-restore.js restore",
"hero:prepare": "node scripts/prepare-hero-variants.mjs", "hero:prepare": "node scripts/prepare-hero-variants.mjs",
"import-spielplan": "node scripts/import-spielplan.js", "import-spielplan": "node scripts/import-spielplan.js",
"publish-spielplan": "node scripts/publish-imported-spielplan.js", "publish-spielplan": "node scripts/publish-imported-spielplan.js",
@@ -43,7 +46,6 @@
"pinia": "^3.0.3", "pinia": "^3.0.3",
"quill": "^2.0.2", "quill": "^2.0.2",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"vue": "^3.5.22" "vue": "^3.5.22"
}, },
"devDependencies": { "devDependencies": {
@@ -58,7 +60,7 @@
"postcss": "^8.5.12", "postcss": "^8.5.12",
"supertest": "^7.1.0", "supertest": "^7.1.0",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"vitest": "^4.0.16", "vitest": "^4.1.8",
"vue-eslint-parser": "^10.2.0" "vue-eslint-parser": "^10.2.0"
}, },
"overrides": { "overrides": {

View File

@@ -6,7 +6,7 @@
Passwort zurücksetzen Passwort zurücksetzen
</h2> </h2>
<p class="mt-2 text-sm text-gray-600"> <p class="mt-2 text-sm text-gray-600">
Geben Sie Ihre E-Mail-Adresse ein, um Ihr Passwort zurückzusetzen Geben Sie Ihre E-Mail-Adresse ein, um einen Reset-Link zu erhalten
</p> </p>
</div> </div>

View File

@@ -0,0 +1,125 @@
<template>
<div class="min-h-full flex items-center justify-center py-16 px-4 sm:px-6 lg:px-8 bg-gray-50">
<div class="max-w-md w-full space-y-8">
<div class="text-center">
<h2 class="text-3xl font-display font-bold text-gray-900">
Neues Passwort setzen
</h2>
<p class="mt-2 text-sm text-gray-600">
Vergeben Sie ein neues Passwort für Ihren Zugang.
</p>
</div>
<div class="bg-white rounded-xl shadow-lg p-8">
<form class="space-y-6" @submit.prevent="handleSubmit">
<div v-if="!token" class="bg-red-50 border border-red-200 rounded-lg p-4">
<p class="text-sm text-red-800">Der Reset-Link ist unvollständig. Fordern Sie bitte einen neuen Link an.</p>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-2">
Neues Passwort
</label>
<input
id="password"
v-model="password"
type="password"
required
minlength="8"
autocomplete="new-password"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600 focus:border-transparent transition-all"
>
</div>
<div>
<label for="passwordRepeat" class="block text-sm font-medium text-gray-700 mb-2">
Neues Passwort wiederholen
</label>
<input
id="passwordRepeat"
v-model="passwordRepeat"
type="password"
required
minlength="8"
autocomplete="new-password"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600 focus:border-transparent transition-all"
>
</div>
<div v-if="errorMessage" class="bg-red-50 border border-red-200 rounded-lg p-4">
<p class="text-sm text-red-800">{{ errorMessage }}</p>
</div>
<div v-if="successMessage" class="bg-green-50 border border-green-200 rounded-lg p-4">
<p class="text-sm text-green-800 flex items-center">
<Check :size="18" class="mr-2" />
{{ successMessage }}
</p>
</div>
<button
type="submit"
:disabled="isLoading || !token || Boolean(successMessage)"
class="w-full px-6 py-3 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-400 text-white font-semibold rounded-lg transition-colors flex items-center justify-center"
>
<Loader2 v-if="isLoading" :size="20" class="mr-2 animate-spin" />
<span>{{ isLoading ? 'Wird gespeichert...' : 'Passwort speichern' }}</span>
</button>
<div class="text-center">
<NuxtLink to="/login" class="text-sm text-primary-600 hover:text-primary-700 font-medium">
Zurück zum Login
</NuxtLink>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { Check, Loader2 } from 'lucide-vue-next'
const route = useRoute()
const token = computed(() => String(route.query.token || '').trim())
const password = ref('')
const passwordRepeat = ref('')
const isLoading = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const handleSubmit = async () => {
errorMessage.value = ''
successMessage.value = ''
if (password.value.length < 8) {
errorMessage.value = 'Das Passwort muss mindestens 8 Zeichen lang sein.'
return
}
if (password.value !== passwordRepeat.value) {
errorMessage.value = 'Die Passwörter stimmen nicht überein.'
return
}
isLoading.value = true
try {
const response = await $fetch('/api/auth/reset-password/complete', {
method: 'POST',
body: { token: token.value, password: password.value }
})
successMessage.value = response?.message || 'Ihr Passwort wurde geändert.'
password.value = ''
passwordRepeat.value = ''
} catch (error) {
errorMessage.value = error?.data?.message || 'Der Reset-Link ist ungültig oder abgelaufen.'
} finally {
isLoading.value = false
}
}
useHead({
title: 'Neues Passwort setzen - Harheimer TC',
})
</script>

View File

@@ -0,0 +1,25 @@
export default defineNuxtPlugin((nuxtApp) => {
const authStore = useAuthStore()
const syncAuthState = async () => {
await authStore.checkAuth()
}
nuxtApp.hook('app:mounted', () => {
syncAuthState()
})
if (typeof window !== 'undefined') {
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
syncAuthState()
}
})
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
syncAuthState()
}
})
}
})

View File

@@ -0,0 +1,165 @@
#!/usr/bin/env node
import path from 'path'
import {
getBackupDirectoryForDataFile,
listDataFileBackups,
restoreDataFileBackup
} from '../server/utils/data-file-rotation.js'
const FILES = {
'users.json': getDataPath('users.json'),
'sessions.json': getDataPath('sessions.json'),
'members.json': getDataPath('members.json'),
'newsletter-subscribers.json': getDataPath('newsletter-subscribers.json'),
'news.json': getDataPath('news.json'),
'termine.csv': getDataPath('termine.csv'),
'contact-requests.json': getDataPath('contact-requests.json')
}
function getDataPath(filename) {
const cwd = process.cwd()
if (cwd.endsWith('.output')) {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
return path.join(cwd, '../server/data', filename)
}
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
return path.join(cwd, 'server/data', filename)
}
function parseArg(name) {
const index = process.argv.findIndex((arg) => arg === name)
if (index === -1) return null
const next = process.argv[index + 1]
if (!next || next.startsWith('--')) return null
return next
}
function hasFlag(name) {
return process.argv.includes(name)
}
function printUsage() {
console.log('Verwendung:')
console.log(' node scripts/data-backup-restore.js list [--file users.json]')
console.log(' node scripts/data-backup-restore.js restore --file users.json --latest')
console.log(' node scripts/data-backup-restore.js restore --file users.json --backup <backup-datei.bak>')
console.log('')
console.log('Optionen:')
console.log(' --file Eine der bekannten Daten-Dateien')
console.log(' --latest Stellt das neueste Backup wieder her')
console.log(' --backup Konkreter Backup-Dateiname (*.bak)')
console.log('')
console.log('Bekannte Dateien:')
Object.keys(FILES).forEach((name) => console.log(` - ${name}`))
}
async function listCommand() {
const requestedFile = parseArg('--file')
const names = requestedFile ? [requestedFile] : Object.keys(FILES)
for (const name of names) {
const dataPath = FILES[name]
if (!dataPath) {
console.error(`Unbekannte Datei: ${name}`)
process.exitCode = 1
continue
}
const backups = await listDataFileBackups(dataPath)
const backupDir = getBackupDirectoryForDataFile(dataPath)
console.log(`\n${name}`)
console.log(` Datenpfad: ${dataPath}`)
console.log(` Backup-Ordner: ${backupDir}`)
if (backups.length === 0) {
console.log(' Backups: keine')
continue
}
console.log(` Backups (${backups.length}, neuestes zuerst):`)
backups.slice(0, 15).forEach((backup) => {
console.log(` - ${backup}`)
})
if (backups.length > 15) {
console.log(` ... (${backups.length - 15} weitere)`)
}
}
}
async function restoreCommand() {
const fileName = parseArg('--file')
if (!fileName) {
console.error('Fehlend: --file <datei>')
printUsage()
process.exit(1)
}
const dataPath = FILES[fileName]
if (!dataPath) {
console.error(`Unbekannte Datei: ${fileName}`)
process.exit(1)
}
const backups = await listDataFileBackups(dataPath)
if (backups.length === 0) {
console.error(`Keine Backups gefunden für ${fileName}`)
process.exit(1)
}
const backupName = parseArg('--backup')
const latest = hasFlag('--latest')
let targetBackup = backupName
if (!targetBackup && latest) {
targetBackup = backups[0]
}
if (!targetBackup) {
console.error('Bitte --latest oder --backup <name> angeben')
process.exit(1)
}
if (!backups.includes(targetBackup)) {
console.error(`Backup nicht gefunden: ${targetBackup}`)
console.error('Nutzen Sie zuerst: node scripts/data-backup-restore.js list --file <datei>')
process.exit(1)
}
const result = await restoreDataFileBackup(dataPath, targetBackup)
console.log(`Wiederherstellung abgeschlossen: ${fileName}`)
console.log(` Eingespieltes Backup: ${targetBackup}`)
if (result.backupPath) {
console.log(` Backup des vorherigen Zustands: ${result.backupPath}`)
}
}
async function main() {
const command = process.argv[2]
if (!command || command === '--help' || command === '-h') {
printUsage()
return
}
if (command === 'list') {
await listCommand()
return
}
if (command === 'restore') {
await restoreCommand()
return
}
console.error(`Unbekannter Befehl: ${command}`)
printUsage()
process.exit(1)
}
main().catch((error) => {
console.error('Fehler im Backup/Restore-Skript:', error)
process.exit(1)
})

View File

@@ -1,10 +1,45 @@
import { readUsers, hashPassword, writeUsers, revokeRefreshSessionsForUser } from '../../utils/auth.js' import { readUsers, writeUsers } from '../../utils/auth.js'
import nodemailer from 'nodemailer' import nodemailer from 'nodemailer'
import crypto from 'crypto' import crypto from 'crypto'
import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js' import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js'
import { writeAuditLog } from '../../utils/audit-log.js' import { writeAuditLog } from '../../utils/audit-log.js'
import { maskResetEmail, normalizeResetEmail, writePasswordResetLog } from '../../utils/password-reset-log.js' import { maskResetEmail, normalizeResetEmail, writePasswordResetLog } from '../../utils/password-reset-log.js'
const RESET_TOKEN_TTL_MINUTES = Number(process.env.PASSWORD_RESET_TTL_MIN || 60)
const RESET_TOKEN_MAX_AGE_MS = RESET_TOKEN_TTL_MINUTES * 60 * 1000
function generateResetToken() {
return crypto.randomBytes(32).toString('base64url')
}
function hashResetToken(token) {
return crypto.createHash('sha256').update(String(token), 'utf8').digest('hex')
}
function getResetBaseUrl(event) {
const configured = process.env.NUXT_PUBLIC_BASE_URL
if (configured) return configured.replace(/\/$/, '')
const requestUrl = getRequestURL(event)
return `${requestUrl.protocol}//${requestUrl.host}`
}
function prunePasswordResetTokens(user) {
const now = Date.now()
user.passwordResetTokens = (Array.isArray(user.passwordResetTokens) ? user.passwordResetTokens : [])
.filter(token => !token.usedAt && new Date(token.expiresAt).getTime() > now)
.slice(-4)
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const requestId = crypto.randomUUID() const requestId = crypto.randomUUID()
let emailKey = '' let emailKey = ''
@@ -34,7 +69,6 @@ export default defineEventHandler(async (event) => {
}) })
} }
// Rate Limiting (IP + Account)
await logStep('rate_limit', 'checking') await logStep('rate_limit', 'checking')
try { try {
assertRateLimit(event, { assertRateLimit(event, {
@@ -57,7 +91,6 @@ export default defineEventHandler(async (event) => {
} }
await logStep('rate_limit', 'passed') await logStep('rate_limit', 'passed')
// Find user
let users let users
try { try {
users = await readUsers() users = await readUsers()
@@ -67,7 +100,6 @@ export default defineEventHandler(async (event) => {
} }
const user = users.find(u => normalizeResetEmail(u.email) === emailKey) const user = users.find(u => normalizeResetEmail(u.email) === emailKey)
// Always return success (security: don't reveal if email exists)
if (!user) { if (!user) {
await logStep('user_lookup', 'not_found') await logStep('user_lookup', 'not_found')
await registerRateLimitFailure(event, { name: 'auth:reset:ip', keyParts: [ip] }) await registerRateLimitFailure(event, { name: 'auth:reset:ip', keyParts: [ip] })
@@ -81,82 +113,84 @@ export default defineEventHandler(async (event) => {
} }
await logStep('user_lookup', 'found', { userId: user.id }) await logStep('user_lookup', 'found', { userId: user.id })
// Generate temporary password
const tempPassword = crypto.randomBytes(8).toString('hex')
const hashedPassword = await hashPassword(tempPassword)
await logStep('temporary_password', 'generated', { userId: user.id })
// Send email with temporary password
const smtpUser = process.env.SMTP_USER const smtpUser = process.env.SMTP_USER
const smtpPass = process.env.SMTP_PASS const smtpPass = process.env.SMTP_PASS
if (!smtpUser || !smtpPass) { if (!smtpUser || !smtpPass) {
await logStep('mail_configuration', 'failed', { userId: user.id, reason: 'smtp_credentials_missing' }) await logStep('mail_configuration', 'failed', { userId: user.id, reason: 'smtp_credentials_missing' })
console.warn('SMTP-Credentials fehlen! E-Mail-Versand wird übersprungen.') console.warn('SMTP-Credentials fehlen! E-Mail-Versand wird übersprungen.')
console.warn(`SMTP_USER=${smtpUser ? 'gesetzt' : 'FEHLT'}, SMTP_PASS=${smtpPass ? 'gesetzt' : 'FEHLT'}`) console.warn(`SMTP_USER=${smtpUser ? 'gesetzt' : 'FEHLT'}, SMTP_PASS=${smtpPass ? 'gesetzt' : 'FEHLT'}`)
throw new Error('SMTP-Konfiguration fuer Passwort-Reset fehlt') throw new Error('SMTP-Konfiguration fuer Passwort-Reset fehlt')
} else {
await logStep('mail_configuration', 'passed', { userId: user.id })
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: process.env.SMTP_PORT || 587,
secure: false,
auth: {
user: smtpUser,
pass: smtpPass
}
})
const mailOptions = {
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
to: user.email,
subject: 'Passwort zurücksetzen - Harheimer TC',
html: `
<h2>Passwort zurücksetzen</h2>
<p>Hallo ${user.name},</p>
<p>Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt.</p>
<p>Ihr temporäres Passwort lautet: <strong>${tempPassword}</strong></p>
<p>Bitte melden Sie sich damit an und ändern Sie Ihr Passwort im Mitgliederbereich.</p>
<br>
<p>Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.</p>
<br>
<p>Mit sportlichen Grüßen,<br>Ihr Harheimer TC</p>
`
}
await logStep('mail_send', 'started', { userId: user.id })
try {
await transporter.sendMail(mailOptions)
} catch (error) {
await logStep('mail_send', 'failed', { userId: user.id, error })
throw error
}
await logStep('mail_send', 'completed', { userId: user.id })
} }
// Erst nach erfolgreichem Versand das zugesandte Passwort aktivieren. await logStep('mail_configuration', 'passed', { userId: user.id })
user.password = hashedPassword
user.passwordResetRequired = true const token = generateResetToken()
const tokenHash = hashResetToken(token)
const nowIso = new Date().toISOString()
const expiresAt = new Date(Date.now() + RESET_TOKEN_MAX_AGE_MS).toISOString()
prunePasswordResetTokens(user)
user.passwordResetTokens.push({
tokenHash,
createdAt: nowIso,
expiresAt,
usedAt: null
})
await logStep('reset_token', 'generated', { userId: user.id, expiresAt })
const updatedUsers = users.map(u => u.id === user.id ? user : u) const updatedUsers = users.map(u => u.id === user.id ? user : u)
let passwordStored = false let tokenStored = false
try { try {
passwordStored = await writeUsers(updatedUsers) tokenStored = await writeUsers(updatedUsers)
} catch (error) { } catch (error) {
await logStep('password_storage', 'failed', { userId: user.id, error }) await logStep('token_storage', 'failed', { userId: user.id, error })
throw error throw error
} }
if (!passwordStored) { if (!tokenStored) {
await logStep('password_storage', 'failed', { userId: user.id, reason: 'write_failed' }) await logStep('token_storage', 'failed', { userId: user.id, reason: 'write_failed' })
throw new Error('Passwort konnte nach E-Mail-Versand nicht gespeichert werden') throw new Error('Reset-Token konnte nicht gespeichert werden')
} }
await logStep('password_storage', 'completed', { userId: user.id }) await logStep('token_storage', 'completed', { userId: user.id })
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: process.env.SMTP_PORT || 587,
secure: process.env.SMTP_SECURE === 'true',
auth: {
user: smtpUser,
pass: smtpPass
}
})
const resetUrl = `${getResetBaseUrl(event)}/passwort-zuruecksetzen?token=${encodeURIComponent(token)}`
const displayName = escapeHtml(user.name || 'Mitglied')
const mailOptions = {
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
to: user.email,
subject: 'Passwort zurücksetzen - Harheimer TC',
html: `
<h2>Passwort zurücksetzen</h2>
<p>Hallo ${displayName},</p>
<p>Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt.</p>
<p>Bitte klicken Sie auf den folgenden Link und vergeben Sie dort ein neues Passwort. Der Link ist ${RESET_TOKEN_TTL_MINUTES} Minuten gültig:</p>
<p><a href="${resetUrl}">Neues Passwort setzen</a></p>
<p>Ihr bisheriges Passwort bleibt gültig, bis Sie über diesen Link ein neues Passwort gesetzt haben.</p>
<br>
<p>Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.</p>
<br>
<p>Mit sportlichen Grüßen,<br>Ihr Harheimer TC</p>
`
}
await logStep('mail_send', 'started', { userId: user.id })
try { try {
await revokeRefreshSessionsForUser(user.id, 'password_reset') await transporter.sendMail(mailOptions)
} catch (error) { } catch (error) {
await logStep('session_revocation', 'failed', { userId: user.id, error }) await logStep('mail_send', 'failed', { userId: user.id, error })
throw error throw error
} }
await logStep('session_revocation', 'completed', { userId: user.id }) await logStep('mail_send', 'completed', { userId: user.id })
registerRateLimitSuccess(event, { name: 'auth:reset:account', keyParts: [emailKey] }) registerRateLimitSuccess(event, { name: 'auth:reset:account', keyParts: [emailKey] })
await writeAuditLog('auth.reset.request', { ip, emailMasked: maskResetEmail(emailKey), userFound: true, userId: user.id, requestId }) await writeAuditLog('auth.reset.request', { ip, emailMasked: maskResetEmail(emailKey), userFound: true, userId: user.id, requestId })
@@ -168,7 +202,6 @@ export default defineEventHandler(async (event) => {
} catch (error) { } catch (error) {
await logStep('request_completed', 'failed', { error }) await logStep('request_completed', 'failed', { error })
console.error('Password-Reset-Fehler:', { requestId, code: error?.code || error?.name || 'Error' }) console.error('Password-Reset-Fehler:', { requestId, code: error?.code || error?.name || 'Error' })
// Don't reveal errors to prevent email enumeration
return { return {
success: true, success: true,
message: 'Falls ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet.' message: 'Falls ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet.'

View File

@@ -0,0 +1,82 @@
import crypto from 'crypto'
import { hashPassword, readUsers, revokeRefreshSessionsForUser, writeUsers } from '../../../utils/auth.js'
import { getClientIp } from '../../../utils/rate-limit.js'
import { writeAuditLog } from '../../../utils/audit-log.js'
import { writePasswordResetLog } from '../../../utils/password-reset-log.js'
function hashResetToken(token) {
return crypto.createHash('sha256').update(String(token), 'utf8').digest('hex')
}
function isStrongEnoughPassword(password) {
return typeof password === 'string' && password.length >= 8
}
export default defineEventHandler(async (event) => {
const requestId = crypto.randomUUID()
const ip = getClientIp(event)
const body = await readBody(event)
const token = String(body?.token || '').trim()
const password = String(body?.password || '')
const logStep = async (step, status, detail = {}) => {
try {
await writePasswordResetLog({ requestId, email: detail.email || '', ip, step, status, ...detail })
} catch (logError) {
console.error('Password-Reset-Diagnoselog-Fehler:', logError)
}
}
if (!token) {
await logStep('complete_validation', 'failed', { reason: 'token_missing' })
throw createError({ statusCode: 400, message: 'Reset-Link fehlt.' })
}
if (!isStrongEnoughPassword(password)) {
await logStep('complete_validation', 'failed', { reason: 'password_too_short' })
throw createError({ statusCode: 400, message: 'Das Passwort muss mindestens 8 Zeichen lang sein.' })
}
const users = await readUsers()
const tokenHash = hashResetToken(token)
const now = Date.now()
let matchedUser = null
let matchedToken = null
for (const user of users) {
const tokens = Array.isArray(user.passwordResetTokens) ? user.passwordResetTokens : []
const candidate = tokens.find(entry => entry.tokenHash === tokenHash)
if (candidate) {
matchedUser = user
matchedToken = candidate
break
}
}
if (!matchedUser || !matchedToken || matchedToken.usedAt || new Date(matchedToken.expiresAt).getTime() <= now) {
await logStep('complete_token', 'failed', { reason: 'invalid_or_expired' })
throw createError({ statusCode: 400, message: 'Der Reset-Link ist ungültig oder abgelaufen.' })
}
const nowIso = new Date().toISOString()
matchedUser.password = await hashPassword(password)
matchedUser.passwordResetRequired = false
matchedToken.usedAt = nowIso
matchedUser.passwordResetTokens = (Array.isArray(matchedUser.passwordResetTokens) ? matchedUser.passwordResetTokens : [])
.filter(entry => entry.usedAt || new Date(entry.expiresAt).getTime() > now)
const stored = await writeUsers(users)
if (!stored) {
await logStep('complete_password_storage', 'failed', { userId: matchedUser.id, email: matchedUser.email, reason: 'write_failed' })
throw createError({ statusCode: 500, message: 'Das neue Passwort konnte nicht gespeichert werden.' })
}
await revokeRefreshSessionsForUser(matchedUser.id, 'password_reset_completed')
await writeAuditLog('auth.reset.complete', { ip, userId: matchedUser.id, requestId })
await logStep('complete_password_storage', 'completed', { userId: matchedUser.id, email: matchedUser.email })
return {
success: true,
message: 'Ihr Passwort wurde geändert. Sie können sich jetzt mit dem neuen Passwort anmelden.'
}
})

View File

@@ -1,5 +1,5 @@
import { readMembers } from '../utils/members.js' import { readMembers } from '../utils/members.js'
import { readUsers, getUserFromToken, verifyToken } from '../utils/auth.js' import { readUsers, getUserFromToken, verifyToken, isHiddenUser, normalizeUserEmail } from '../utils/auth.js'
// Helper: returns array of upcoming birthdays within daysAhead (inclusive) // Helper: returns array of upcoming birthdays within daysAhead (inclusive)
function getUpcomingBirthdays(entries, daysAhead = 28) { function getUpcomingBirthdays(entries, daysAhead = 28) {
@@ -53,10 +53,14 @@ export default defineEventHandler(async (event) => {
const manualMembers = await readMembers() const manualMembers = await readMembers()
const registeredUsers = await readUsers() const registeredUsers = await readUsers()
const hiddenUserEmails = new Set(registeredUsers.filter(isHiddenUser).map(user => normalizeUserEmail(user.email)).filter(Boolean))
// Build unified list of candidates with geburtsdatum and visibility // Build unified list of candidates with geburtsdatum and visibility
const candidates = [] const candidates = []
for (const m of manualMembers) { for (const m of manualMembers) {
const memberEmail = normalizeUserEmail(m.email)
if (m.hidden === true || m.invisible === true || m.isHidden === true || hiddenUserEmails.has(memberEmail)) continue
const normalizedStatus = m.status ? String(m.status).toLowerCase() : '' const normalizedStatus = m.status ? String(m.status).toLowerCase() : ''
const hasExplicitAcceptanceFlag = m.active !== undefined || m.accepted !== undefined || normalizedStatus !== '' const hasExplicitAcceptanceFlag = m.active !== undefined || m.accepted !== undefined || normalizedStatus !== ''
const isAccepted = hasExplicitAcceptanceFlag const isAccepted = hasExplicitAcceptanceFlag
@@ -73,7 +77,7 @@ export default defineEventHandler(async (event) => {
} }
for (const u of registeredUsers) { for (const u of registeredUsers) {
if (!u.active) continue if (!u.active || isHiddenUser(u)) continue
const vis = u.visibility || {} const vis = u.visibility || {}
const showBirthday = vis.showBirthday === undefined ? true : Boolean(vis.showBirthday) const showBirthday = vis.showBirthday === undefined ? true : Boolean(vis.showBirthday)
candidates.push({ name: u.name, geburtsdatum: u.geburtsdatum, visibility: { showBirthday }, source: 'login' }) candidates.push({ name: u.name, geburtsdatum: u.geburtsdatum, visibility: { showBirthday }, source: 'login' })

View File

@@ -1,4 +1,4 @@
import { getUserFromToken, hasRole, readUsers } from '../../utils/auth.js' import { getUserFromToken, hasRole, readUsers, isHiddenUser } from '../../utils/auth.js'
import { import {
fingerprintResetEmail, fingerprintResetEmail,
normalizeResetEmail, normalizeResetEmail,
@@ -59,17 +59,20 @@ export default defineEventHandler(async (event) => {
const email = normalizeResetEmail(query.email) const email = normalizeResetEmail(query.email)
const failedOnly = query.failedOnly !== 'false' const failedOnly = query.failedOnly !== 'false'
const users = await readUsers() const users = await readUsers()
const visibleUsers = users.filter(user => !isHiddenUser(user))
const hiddenEmailFingerprints = new Set(users.filter(isHiddenUser).map(user => fingerprintResetEmail(user.email)).filter(Boolean))
const logs = await readPasswordResetLogs() const logs = await readPasswordResetLogs()
const filteredLogs = email const filteredLogs = (email
? logs.filter(entry => entry.emailFingerprint === fingerprintResetEmail(email)) ? logs.filter(entry => entry.emailFingerprint === fingerprintResetEmail(email))
: logs : logs)
.filter(entry => !hiddenEmailFingerprints.has(entry.emailFingerprint))
const attempts = summarizeAttempts(filteredLogs) const attempts = summarizeAttempts(filteredLogs)
.filter(attempt => !failedOnly || attempt.failed) .filter(attempt => !failedOnly || attempt.failed)
let matchingUsers = [] let matchingUsers = []
if (email) { if (email) {
const term = email.toLowerCase() const term = email.toLowerCase()
matchingUsers = users matchingUsers = visibleUsers
.filter(user => { .filter(user => {
const userEmail = normalizeResetEmail(user.email) const userEmail = normalizeResetEmail(user.email)
const name = String(user.name || '').toLowerCase() const name = String(user.name || '').toLowerCase()

View File

@@ -1,4 +1,4 @@
import { getUserFromToken, readUsers, hasAnyRole, migrateUserRoles } from '../../../utils/auth.js' import { getUserFromToken, readUsers, hasAnyRole, migrateUserRoles, isHiddenUser } from '../../../utils/auth.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
@@ -18,7 +18,7 @@ export default defineEventHandler(async (event) => {
// Nur Admin oder Vorstand duerfen vollen Benutzer-Contact und Rollen sehen. // Nur Admin oder Vorstand duerfen vollen Benutzer-Contact und Rollen sehen.
const canSeePrivate = hasAnyRole(currentUser, 'admin', 'vorstand') const canSeePrivate = hasAnyRole(currentUser, 'admin', 'vorstand')
const safeUsers = users.map(u => { const safeUsers = users.filter(u => !isHiddenUser(u)).map(u => {
const migrated = migrateUserRoles({ ...u }) const migrated = migrateUserRoles({ ...u })
const roles = Array.isArray(migrated.roles) ? migrated.roles : (migrated.role ? [migrated.role] : ['mitglied']) const roles = Array.isArray(migrated.roles) ? migrated.roles : (migrated.role ? [migrated.role] : ['mitglied'])

View File

@@ -2,7 +2,7 @@ import nodemailer from 'nodemailer'
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
import path from 'path' import path from 'path'
import { createContactRequest } from '../utils/contact-requests.js' import { createContactRequest } from '../utils/contact-requests.js'
import { readUsers, migrateUserRoles } from '../utils/auth.js' import { readUsers, migrateUserRoles, isHiddenUser } from '../utils/auth.js'
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is always a hardcoded constant ('config.json'), never user input // filename is always a hardcoded constant ('config.json'), never user input
@@ -54,6 +54,7 @@ async function collectRecipients(config) {
try { try {
const users = await readUsers() const users = await readUsers()
for (const rawUser of users) { for (const rawUser of users) {
if (isHiddenUser(rawUser)) continue
const user = migrateUserRoles({ ...rawUser }) const user = migrateUserRoles({ ...rawUser })
const roles = Array.isArray(user.roles) ? user.roles : [] const roles = Array.isArray(user.roles) ? user.roles : []
if (roles.includes('trainer') && user.email && String(user.email).trim()) { if (roles.includes('trainer') && user.email && String(user.email).trim()) {

View File

@@ -1,6 +1,6 @@
import { verifyToken, getUserFromToken, hasRole } from '../utils/auth.js' import { verifyToken, getUserFromToken, hasRole } from '../utils/auth.js'
import { readMembers } from '../utils/members.js' import { readMembers } from '../utils/members.js'
import { readUsers, migrateUserRoles } from '../utils/auth.js' import { readUsers, migrateUserRoles, isHiddenUser, normalizeUserEmail } from '../utils/auth.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
@@ -52,7 +52,7 @@ export default defineEventHandler(async (event) => {
// Skip applications that are not yet accepted // Skip applications that are not yet accepted
continue continue
} }
const normalizedEmail = member.email?.toLowerCase().trim() || '' const normalizedEmail = normalizeUserEmail(member.email)
const fullName = `${member.firstName || ''} ${member.lastName || ''}`.trim() const fullName = `${member.firstName || ''} ${member.lastName || ''}`.trim()
const normalizedName = fullName.toLowerCase() const normalizedName = fullName.toLowerCase()
@@ -90,11 +90,13 @@ export default defineEventHandler(async (event) => {
} }
} }
const hiddenUserEmails = new Set(registeredUsers.filter(isHiddenUser).map(user => normalizeUserEmail(user.email)).filter(Boolean))
// Then add registered users (only active ones) // Then add registered users (only active ones)
for (const user of registeredUsers) { for (const user of registeredUsers) {
if (!user.active) continue if (!user.active || isHiddenUser(user)) continue
const normalizedEmail = user.email?.toLowerCase().trim() || '' const normalizedEmail = normalizeUserEmail(user.email)
const normalizedName = user.name?.toLowerCase().trim() || '' const normalizedName = user.name?.toLowerCase().trim() || ''
// Hilfsfunktion: Extrahiere Vorname/Nachname aus user.name // Hilfsfunktion: Extrahiere Vorname/Nachname aus user.name
@@ -208,7 +210,10 @@ export default defineEventHandler(async (event) => {
const isPrivilegedViewer = currentUser ? hasRole(currentUser, 'vorstand') : false const isPrivilegedViewer = currentUser ? hasRole(currentUser, 'vorstand') : false
// Filtere den Admin-Account heraus // Filtere den Admin-Account heraus
const filteredMembers = mergedMembers.filter(m => m.email?.toLowerCase() !== 'admin@harheimertc.de') const filteredMembers = mergedMembers.filter(m => {
const email = normalizeUserEmail(m.email || m.loginEmail)
return email !== 'admin@harheimertc.de' && !hiddenUserEmails.has(email) && !m.hidden && !m.invisible && !m.isHidden
})
const sanitizedMembers = filteredMembers.map(member => { const sanitizedMembers = filteredMembers.map(member => {
// Default: show email/phone/address to other logged-in members unless member.visibility explicitly hides them // Default: show email/phone/address to other logged-in members unless member.visibility explicitly hides them
const visibility = member.visibility || {} const visibility = member.visibility || {}

View File

@@ -1,8 +1,7 @@
import { readFile } from 'fs/promises' import { readFile } from 'fs/promises'
import { getServerDataPath } from '../../utils/paths.js' import { getServerDataPath } from '../../utils/paths.js'
import { getUserFromToken, verifyToken } from '../../utils/auth.js' import { getUserFromToken, verifyToken, readUsers, isHiddenUser, normalizeUserEmail } from '../../utils/auth.js'
import { readMembers } from '../../utils/members.js' import { readMembers } from '../../utils/members.js'
import { readUsers } from '../../utils/auth.js'
const QTTR_FILE = getServerDataPath('qttr-values.json') const QTTR_FILE = getServerDataPath('qttr-values.json')
@@ -62,15 +61,27 @@ export default defineEventHandler(async (event) => {
readMembers(), readMembers(),
readUsers() readUsers()
]) ])
const birthdateLookup = buildBirthdateLookup([...manualMembers, ...registeredUsers]) const hiddenUserEmails = new Set(registeredUsers.filter(isHiddenUser).map(user => normalizeUserEmail(user.email)).filter(Boolean))
const visibleManualMembers = manualMembers.filter(member => {
const email = normalizeUserEmail(member.email)
return member.hidden !== true && member.invisible !== true && member.isHidden !== true && !hiddenUserEmails.has(email)
})
const visibleUsers = registeredUsers.filter(user => !isHiddenUser(user))
const hiddenNames = new Set([
...manualMembers.filter(member => member.hidden === true || member.invisible === true || member.isHidden === true || hiddenUserEmails.has(normalizeUserEmail(member.email))),
...registeredUsers.filter(isHiddenUser)
].flatMap(entry => [entry?.name, `${entry?.firstName || ''} ${entry?.lastName || ''}`.trim()]).map(normalizeName).filter(Boolean))
const birthdateLookup = buildBirthdateLookup([...visibleManualMembers, ...visibleUsers])
return { return {
...payload, ...payload,
rows: Array.isArray(payload.rows) rows: Array.isArray(payload.rows)
? payload.rows.map((row) => ({ ? payload.rows
...row, .filter(row => !hiddenNames.has(normalizeName(row.playerName)))
birthdate: birthdateLookup.get(normalizeName(row.playerName)) || row.birthdate || '' .map((row) => ({
})) ...row,
birthdate: birthdateLookup.get(normalizeName(row.playerName)) || row.birthdate || ''
}))
: [] : []
} }
} catch (error) { } catch (error) {

View File

@@ -1,5 +1,6 @@
import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js' import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js'
import { saveNews } from '../utils/news.js' import { saveNews } from '../utils/news.js'
import { sendNewNewsPush } from '../utils/push-notifications.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
@@ -41,7 +42,7 @@ export default defineEventHandler(async (event) => {
}) })
} }
await saveNews({ const newsEntry = {
id: id || undefined, id: id || undefined,
title, title,
content, content,
@@ -49,7 +50,17 @@ export default defineEventHandler(async (event) => {
expiresAt: expiresAt || undefined, expiresAt: expiresAt || undefined,
isHidden: isHidden || false, isHidden: isHidden || false,
author: user.name author: user.name
}) }
await saveNews(newsEntry)
if (!id && !newsEntry.isHidden) {
sendNewNewsPush(newsEntry)
.then(result => {
console.info('News-Push Ergebnis:', { newsId: newsEntry.id, ...result })
})
.catch(error => {
console.error('News-Push konnte nicht gesendet werden:', error)
})
}
return { return {
success: true, success: true,

View File

@@ -0,0 +1,24 @@
import { verifyToken, getUserFromToken } from '../../utils/auth.js'
import { notificationSettingsForUser } from '../../utils/notification-settings.js'
function tokenFromEvent(event) {
return getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
}
async function requireAuthenticatedUser(event) {
const token = tokenFromEvent(event)
if (!token) throw createError({ statusCode: 401, message: 'Nicht authentifiziert.' })
const decoded = verifyToken(token)
if (!decoded) throw createError({ statusCode: 401, message: 'Ungültiges Token.' })
const user = await getUserFromToken(token)
if (!user) throw createError({ statusCode: 401, message: 'Ungültige Sitzung.' })
return { token, decoded, user }
}
export default defineEventHandler(async (event) => {
const { user } = await requireAuthenticatedUser(event)
return {
success: true,
settings: notificationSettingsForUser(user)
}
})

View File

@@ -0,0 +1,34 @@
import { verifyToken, getUserFromToken, readUsers, writeUsers } from '../../utils/auth.js'
import { sanitizeNotificationSettings } from '../../utils/notification-settings.js'
function tokenFromEvent(event) {
return getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
}
async function requireAuthenticatedUser(event) {
const token = tokenFromEvent(event)
if (!token) throw createError({ statusCode: 401, message: 'Nicht authentifiziert.' })
const decoded = verifyToken(token)
if (!decoded) throw createError({ statusCode: 401, message: 'Ungültiges Token.' })
const user = await getUserFromToken(token)
if (!user) throw createError({ statusCode: 401, message: 'Ungültige Sitzung.' })
return { token, decoded, user }
}
export default defineEventHandler(async (event) => {
const { decoded } = await requireAuthenticatedUser(event)
const body = await readBody(event)
const settings = sanitizeNotificationSettings(body?.settings || body || {})
const users = await readUsers()
const userIndex = users.findIndex(user => user.id === decoded.id)
if (userIndex === -1) {
throw createError({ statusCode: 404, message: 'Benutzer nicht gefunden.' })
}
users[userIndex].notificationSettings = settings
await writeUsers(users)
return {
success: true,
message: 'Benachrichtigungseinstellungen gespeichert.',
settings
}
})

View File

@@ -0,0 +1,29 @@
import { verifyToken, getUserFromToken, readUsers, writeUsers } from '../../utils/auth.js'
import { upsertPushToken } from '../../utils/push-notifications.js'
function tokenFromEvent(event) {
return getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
}
export default defineEventHandler(async (event) => {
const token = tokenFromEvent(event)
if (!token) throw createError({ statusCode: 401, message: 'Nicht authentifiziert.' })
const decoded = verifyToken(token)
if (!decoded) throw createError({ statusCode: 401, message: 'Ungültiges Token.' })
const sessionUser = await getUserFromToken(token)
if (!sessionUser) throw createError({ statusCode: 401, message: 'Ungültige Sitzung.' })
const body = await readBody(event)
if (!body?.token || typeof body.token !== 'string') {
throw createError({ statusCode: 400, message: 'Push-Token fehlt.' })
}
const users = await readUsers()
const userIndex = users.findIndex(user => user.id === decoded.id)
if (userIndex === -1) throw createError({ statusCode: 404, message: 'Benutzer nicht gefunden.' })
upsertPushToken(users[userIndex], {
token: body.token,
platform: body.platform || 'android',
appVersion: body.appVersion || null
})
await writeUsers(users)
return { success: true, message: 'Push-Token gespeichert.' }
})

View File

@@ -4,6 +4,7 @@ import crypto from 'crypto'
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
import path from 'path' import path from 'path'
import { encryptObject, decryptObject } from './encryption.js' import { encryptObject, decryptObject } from './encryption.js'
import { writeDataFileWithRotation } from './data-file-rotation.js'
// Export migrateUserRoles für Verwendung in anderen Modulen // Export migrateUserRoles für Verwendung in anderen Modulen
export function migrateUserRoles(user) { export function migrateUserRoles(user) {
@@ -26,6 +27,28 @@ export function migrateUserRoles(user) {
return user return user
} }
export function normalizeUserEmail(email) {
return String(email || '').trim().toLowerCase()
}
function configuredHiddenUserEmails() {
return [process.env.PLAYSTORE_REVIEW_EMAIL, process.env.HIDDEN_USER_EMAILS]
.filter(Boolean)
.flatMap(value => String(value).split(','))
.map(normalizeUserEmail)
.filter(Boolean)
}
export function isHiddenUser(user) {
if (!user) return false
if (user.hidden === true || user.invisible === true || user.isHidden === true || user.systemAccount === true) return true
if (String(user.accountType || '').toLowerCase() === 'playstore_review') return true
const email = normalizeUserEmail(user.email)
return email ? configuredHiddenUserEmails().includes(email) : false
}
const JWT_SECRET = process.env.JWT_SECRET || 'harheimertc-secret-key-change-in-production' const JWT_SECRET = process.env.JWT_SECRET || 'harheimertc-secret-key-change-in-production'
// Handle both dev and production paths // Handle both dev and production paths
@@ -196,7 +219,7 @@ export async function writeUsers(users) {
try { try {
const encryptionKey = getEncryptionKey() const encryptionKey = getEncryptionKey()
const encryptedData = encryptObject(users, encryptionKey) const encryptedData = encryptObject(users, encryptionKey)
await fs.writeFile(USERS_FILE, encryptedData, 'utf-8') await writeDataFileWithRotation(USERS_FILE, encryptedData, { encoding: 'utf-8' })
return true return true
} catch (error) { } catch (error) {
console.error('Fehler beim Schreiben der Benutzerdaten:', error) console.error('Fehler beim Schreiben der Benutzerdaten:', error)
@@ -262,7 +285,7 @@ export async function writeSessions(sessions) {
try { try {
const encryptionKey = getEncryptionKey() const encryptionKey = getEncryptionKey()
const encryptedData = encryptObject(sessions, encryptionKey) const encryptedData = encryptObject(sessions, encryptionKey)
await fs.writeFile(SESSIONS_FILE, encryptedData, 'utf-8') await writeDataFileWithRotation(SESSIONS_FILE, encryptedData, { encoding: 'utf-8' })
return true return true
} catch (error) { } catch (error) {
console.error('Fehler beim Schreiben der Sessions:', error) console.error('Fehler beim Schreiben der Sessions:', error)

View File

@@ -1,6 +1,7 @@
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
import path from 'path' import path from 'path'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import { writeDataFileWithRotation } from './data-file-rotation.js'
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is always a hardcoded constant, never user input // filename is always a hardcoded constant, never user input
@@ -29,7 +30,7 @@ export async function readContactRequests() {
} }
export async function writeContactRequests(items) { export async function writeContactRequests(items) {
await fs.writeFile(CONTACT_REQUESTS_FILE, JSON.stringify(items, null, 2), 'utf-8') await writeDataFileWithRotation(CONTACT_REQUESTS_FILE, JSON.stringify(items, null, 2), { encoding: 'utf-8' })
} }
export async function createContactRequest(data) { export async function createContactRequest(data) {

View File

@@ -0,0 +1,143 @@
import { promises as fs } from 'fs'
import path from 'path'
const DEFAULT_MAX_BACKUPS = Number.parseInt(process.env.DATA_FILE_BACKUP_MAX || '40', 10)
function getProjectRoot() {
const cwd = process.cwd()
if (cwd.endsWith('.output')) {
return path.resolve(cwd, '..')
}
return cwd
}
function getBackupRoot() {
const configured = process.env.DATA_FILE_BACKUP_DIR
if (!configured) {
return path.join(getProjectRoot(), 'backups', 'data-rotation')
}
if (path.isAbsolute(configured)) {
return configured
}
return path.join(getProjectRoot(), configured)
}
function sanitizeFileKey(filePath) {
const projectRoot = getProjectRoot()
const relative = path.relative(projectRoot, filePath)
const normalized = relative.split(path.sep).join('__')
return normalized.replace(/[^a-zA-Z0-9._-]/g, '_')
}
function buildBackupName(date = new Date()) {
const randomSuffix = Math.random().toString(36).slice(2, 8)
return `${date.toISOString().replace(/[:.]/g, '-')}-${randomSuffix}.bak`
}
export function resolveDataFileBackupPath(backupDir, backupName) {
if (typeof backupName !== 'string' || !backupName.endsWith('.bak') || path.basename(backupName) !== backupName) {
throw new Error('Ungueltiger Backup-Dateiname')
}
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const resolvedBackupDir = path.resolve(backupDir)
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const resolvedBackupPath = path.resolve(resolvedBackupDir, backupName)
if (path.dirname(resolvedBackupPath) !== resolvedBackupDir) {
throw new Error('Backup-Datei liegt ausserhalb des Backup-Ordners')
}
return resolvedBackupPath
}
async function ensureDirectory(dirPath) {
await fs.mkdir(dirPath, { recursive: true })
}
async function rotateOldBackups(backupDir, maxBackups) {
if (!Number.isFinite(maxBackups) || maxBackups < 1) {
return
}
const entries = await fs.readdir(backupDir, { withFileTypes: true }).catch(() => [])
const backups = entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.bak'))
.map((entry) => entry.name)
.sort()
const overflowCount = Math.max(0, backups.length - maxBackups)
if (overflowCount === 0) {
return
}
const toDelete = backups.slice(0, overflowCount)
await Promise.all(toDelete.map((name) => fs.unlink(resolveDataFileBackupPath(backupDir, name)).catch(() => {})))
}
export function getBackupDirectoryForDataFile(filePath) {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const resolvedPath = path.resolve(filePath)
return path.join(getBackupRoot(), sanitizeFileKey(resolvedPath))
}
export async function listDataFileBackups(filePath) {
const backupDir = getBackupDirectoryForDataFile(filePath)
const entries = await fs.readdir(backupDir, { withFileTypes: true }).catch(() => [])
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.bak'))
.map((entry) => entry.name)
.sort()
.reverse()
}
export async function writeDataFileWithRotation(filePath, content, {
encoding = 'utf-8',
maxBackups = DEFAULT_MAX_BACKUPS
} = {}) {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const resolvedPath = path.resolve(filePath)
await ensureDirectory(path.dirname(resolvedPath))
let existingContent = null
try {
existingContent = await fs.readFile(resolvedPath, encoding)
} catch (error) {
if (error.code !== 'ENOENT') {
throw error
}
}
if (existingContent !== null && existingContent === content) {
return {
changed: false,
backupPath: null
}
}
let backupPath = null
if (existingContent !== null) {
const backupDir = getBackupDirectoryForDataFile(resolvedPath)
await ensureDirectory(backupDir)
backupPath = resolveDataFileBackupPath(backupDir, buildBackupName())
await fs.copyFile(resolvedPath, backupPath)
await rotateOldBackups(backupDir, maxBackups)
}
const tmpPath = `${resolvedPath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`
await fs.writeFile(tmpPath, content, encoding)
await fs.rename(tmpPath, resolvedPath)
return {
changed: true,
backupPath
}
}
export async function restoreDataFileBackup(filePath, backupName, options = {}) {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const resolvedPath = path.resolve(filePath)
const backupDir = getBackupDirectoryForDataFile(resolvedPath)
const sourceBackupPath = resolveDataFileBackupPath(backupDir, backupName)
const backupContent = await fs.readFile(sourceBackupPath, 'utf-8')
return writeDataFileWithRotation(resolvedPath, backupContent, options)
}

View File

@@ -2,6 +2,7 @@ import { promises as fs } from 'fs'
import path from 'path' import path from 'path'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import { encrypt, decrypt, encryptObject, decryptObject } from './encryption.js' import { encrypt, decrypt, encryptObject, decryptObject } from './encryption.js'
import { writeDataFileWithRotation } from './data-file-rotation.js'
// Handle both dev and production paths // Handle both dev and production paths
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
@@ -192,7 +193,7 @@ export async function writeMembers(members) {
try { try {
const encryptionKey = getEncryptionKey() const encryptionKey = getEncryptionKey()
const encryptedData = encryptObject(members, encryptionKey) const encryptedData = encryptObject(members, encryptionKey)
await fs.writeFile(MEMBERS_FILE, encryptedData, 'utf-8') await writeDataFileWithRotation(MEMBERS_FILE, encryptedData, { encoding: 'utf-8' })
return true return true
} catch (error) { } catch (error) {
console.error('Fehler beim Schreiben der Mitgliederdaten:', error) console.error('Fehler beim Schreiben der Mitgliederdaten:', error)

View File

@@ -1,6 +1,7 @@
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
import path from 'path' import path from 'path'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import { writeDataFileWithRotation } from './data-file-rotation.js'
// Handle both dev and production paths // Handle both dev and production paths
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
@@ -38,7 +39,7 @@ export async function readNews() {
// Write news to file // Write news to file
export async function writeNews(news) { export async function writeNews(news) {
try { try {
await fs.writeFile(NEWS_FILE, JSON.stringify(news, null, 2), 'utf-8') await writeDataFileWithRotation(NEWS_FILE, JSON.stringify(news, null, 2), { encoding: 'utf-8' })
return true return true
} catch (error) { } catch (error) {
console.error('Fehler beim Schreiben der News:', error) console.error('Fehler beim Schreiben der News:', error)

View File

@@ -1,9 +1,10 @@
import fs from 'fs/promises' import fs from 'fs/promises'
import path from 'path' import path from 'path'
import { readMembers } from './members.js' import { readMembers } from './members.js'
import { readUsers } from './auth.js' import { readUsers, isHiddenUser, normalizeUserEmail } from './auth.js'
import { encryptObject, decryptObject } from './encryption.js' import { encryptObject, decryptObject } from './encryption.js'
import crypto from 'crypto' import crypto from 'crypto'
import { writeDataFileWithRotation } from './data-file-rotation.js'
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is always a hardcoded constant (e.g., 'newsletter-subscribers.json'), never user input // filename is always a hardcoded constant (e.g., 'newsletter-subscribers.json'), never user input
@@ -136,7 +137,7 @@ export async function writeSubscribers(subscribers) {
try { try {
const encryptionKey = getEncryptionKey() const encryptionKey = getEncryptionKey()
const encryptedData = encryptObject(subscribers, encryptionKey) const encryptedData = encryptObject(subscribers, encryptionKey)
await fs.writeFile(NEWSLETTER_SUBSCRIBERS_FILE, encryptedData, 'utf-8') await writeDataFileWithRotation(NEWSLETTER_SUBSCRIBERS_FILE, encryptedData, { encoding: 'utf-8' })
return true return true
} catch (error) { } catch (error) {
console.error('Fehler beim Schreiben der Newsletter-Abonnenten:', error) console.error('Fehler beim Schreiben der Newsletter-Abonnenten:', error)
@@ -161,11 +162,11 @@ function calculateAge(geburtsdatum) {
} }
} }
// Filtert den Admin-User aus Empfängerliste heraus // Filtert interne System-/Hidden-Accounts aus Empfängerliste heraus
function filterAdminUser(recipients) { function filterInternalUsers(recipients, hiddenEmails = new Set()) {
return recipients.filter(r => { return recipients.filter(r => {
const email = (r.email || '').toLowerCase().trim() const email = normalizeUserEmail(r.email)
return email !== 'admin@harheimertc.de' return email && email !== 'admin@harheimertc.de' && !hiddenEmails.has(email)
}) })
} }
@@ -173,20 +174,26 @@ function filterAdminUser(recipients) {
export async function getRecipientsByGroup(targetGroup) { export async function getRecipientsByGroup(targetGroup) {
const members = await readMembers() const members = await readMembers()
const users = await readUsers() const users = await readUsers()
const hiddenUserEmails = new Set(users.filter(isHiddenUser).map(user => normalizeUserEmail(user.email)).filter(Boolean))
const visibleUsers = users.filter(user => !isHiddenUser(user))
const visibleMembers = members.filter(member => {
const email = normalizeUserEmail(member.email)
return member.hidden !== true && member.invisible !== true && member.isHidden !== true && !hiddenUserEmails.has(email)
})
let recipients = [] let recipients = []
switch (targetGroup) { switch (targetGroup) {
case 'alle': case 'alle':
// Alle Mitglieder mit E-Mail // Alle Mitglieder mit E-Mail
recipients = members recipients = visibleMembers
.filter(m => m.email && m.email.trim() !== '') .filter(m => m.email && m.email.trim() !== '')
.map(m => ({ .map(m => ({
email: m.email, email: m.email,
name: `${m.firstName || ''} ${m.lastName || ''}`.trim() || m.name || '' name: `${m.firstName || ''} ${m.lastName || ''}`.trim() || m.name || ''
})) }))
// Auch alle aktiven Benutzer hinzufügen // Auch alle aktiven Benutzer hinzufügen
users visibleUsers
.filter(u => u.active && u.email) .filter(u => u.active && u.email)
.forEach(u => { .forEach(u => {
if (!recipients.find(r => r.email.toLowerCase() === u.email.toLowerCase())) { if (!recipients.find(r => r.email.toLowerCase() === u.email.toLowerCase())) {
@@ -200,7 +207,7 @@ export async function getRecipientsByGroup(targetGroup) {
case 'erwachsene': case 'erwachsene':
// Mitglieder über 18 Jahre // Mitglieder über 18 Jahre
recipients = members recipients = visibleMembers
.filter(m => { .filter(m => {
if (!m.email || !m.email.trim()) return false if (!m.email || !m.email.trim()) return false
const age = calculateAge(m.geburtsdatum) const age = calculateAge(m.geburtsdatum)
@@ -211,7 +218,7 @@ export async function getRecipientsByGroup(targetGroup) {
name: `${m.firstName || ''} ${m.lastName || ''}`.trim() || m.name || '' name: `${m.firstName || ''} ${m.lastName || ''}`.trim() || m.name || ''
})) }))
// Auch aktive Benutzer hinzufügen (als Erwachsene behandelt, wenn kein Geburtsdatum) // Auch aktive Benutzer hinzufügen (als Erwachsene behandelt, wenn kein Geburtsdatum)
users visibleUsers
.filter(u => u.active && u.email && u.email.trim()) .filter(u => u.active && u.email && u.email.trim())
.forEach(u => { .forEach(u => {
// Prüfe ob bereits vorhanden // Prüfe ob bereits vorhanden
@@ -226,7 +233,7 @@ export async function getRecipientsByGroup(targetGroup) {
case 'nachwuchs': case 'nachwuchs':
// Mitglieder unter 18 Jahre // Mitglieder unter 18 Jahre
recipients = members recipients = visibleMembers
.filter(m => { .filter(m => {
if (!m.email || !m.email.trim()) return false if (!m.email || !m.email.trim()) return false
const age = calculateAge(m.geburtsdatum) const age = calculateAge(m.geburtsdatum)
@@ -238,7 +245,7 @@ export async function getRecipientsByGroup(targetGroup) {
})) }))
// Zusätzlich aktive Trainer aus users.json anschreiben // Zusätzlich aktive Trainer aus users.json anschreiben
users visibleUsers
.filter(u => { .filter(u => {
if (!u.active || !u.email || !u.email.trim()) return false if (!u.active || !u.email || !u.email.trim()) return false
const roles = Array.isArray(u.roles) ? u.roles : (u.role ? [u.role] : []) const roles = Array.isArray(u.roles) ? u.roles : (u.role ? [u.role] : [])
@@ -256,7 +263,7 @@ export async function getRecipientsByGroup(targetGroup) {
case 'mannschaftsspieler': case 'mannschaftsspieler':
// Mitglieder die in einer Mannschaft spielen // Mitglieder die in einer Mannschaft spielen
recipients = members recipients = visibleMembers
.filter(m => { .filter(m => {
if (!m.email || !m.email.trim()) return false if (!m.email || !m.email.trim()) return false
// Prüfe ob als Mannschaftsspieler markiert // Prüfe ob als Mannschaftsspieler markiert
@@ -275,7 +282,7 @@ export async function getRecipientsByGroup(targetGroup) {
case 'vorstand': case 'vorstand':
// Nur Vorstand (aus users.json) // Nur Vorstand (aus users.json)
recipients = users recipients = visibleUsers
.filter(u => { .filter(u => {
if (!u.active || !u.email) return false if (!u.active || !u.email) return false
const roles = Array.isArray(u.roles) ? u.roles : (u.role ? [u.role] : []) const roles = Array.isArray(u.roles) ? u.roles : (u.role ? [u.role] : [])
@@ -292,12 +299,13 @@ export async function getRecipientsByGroup(targetGroup) {
} }
// Admin-User herausfiltern // Admin-User herausfiltern
return filterAdminUser(recipients) return filterInternalUsers(recipients, hiddenUserEmails)
} }
// Holt Newsletter-Abonnenten (bestätigt und nicht abgemeldet) // Holt Newsletter-Abonnenten (bestätigt und nicht abgemeldet)
export async function getNewsletterSubscribers(internalOnly = false, groupId = null) { export async function getNewsletterSubscribers(internalOnly = false, groupId = null) {
const subscribers = await readSubscribers() const subscribers = await readSubscribers()
const hiddenUserEmails = new Set((await readUsers()).filter(isHiddenUser).map(user => normalizeUserEmail(user.email)).filter(Boolean))
let confirmedSubscribers = subscribers.filter(s => { let confirmedSubscribers = subscribers.filter(s => {
if (!s.confirmed || s.unsubscribedAt) { if (!s.confirmed || s.unsubscribedAt) {
@@ -328,12 +336,12 @@ export async function getNewsletterSubscribers(internalOnly = false, groupId = n
const members = await readMembers() const members = await readMembers()
const memberEmails = new Set( const memberEmails = new Set(
members members
.filter(m => m.email) .filter(m => m.email && m.hidden !== true && m.invisible !== true && m.isHidden !== true && !hiddenUserEmails.has(normalizeUserEmail(m.email)))
.map(m => m.email.toLowerCase()) .map(m => normalizeUserEmail(m.email))
) )
confirmedSubscribers = confirmedSubscribers.filter(s => confirmedSubscribers = confirmedSubscribers.filter(s =>
memberEmails.has(s.email.toLowerCase()) memberEmails.has(normalizeUserEmail(s.email))
) )
} }
@@ -343,7 +351,7 @@ export async function getNewsletterSubscribers(internalOnly = false, groupId = n
})) }))
// Admin-User herausfiltern // Admin-User herausfiltern
return filterAdminUser(result) return filterInternalUsers(result, hiddenUserEmails)
} }
// Generiert Abmelde-Token für Abonnenten // Generiert Abmelde-Token für Abonnenten

View File

@@ -0,0 +1,55 @@
export const DEFAULT_NOTIFICATION_SETTINGS = Object.freeze({
newNews: false,
newEvents: false,
eventsToday: false,
eventsTomorrow: false,
ownTeamMatches: false,
allTeamMatches: false,
birthdays: false,
newContactRequest: false,
newUserRegistration: false,
selectedTeamSlugs: [],
selectedTeamSeason: null,
notificationTime: '09:00'
})
function coerceBoolean(value) {
return value === true
}
export function sanitizeNotificationSettings(input = {}) {
const selectedTeamSlugs = Array.isArray(input.selectedTeamSlugs)
? input.selectedTeamSlugs
.map(value => String(value || '').trim())
.filter(Boolean)
.slice(0, 50)
: []
const selectedTeamSeason = typeof input.selectedTeamSeason === 'string' && input.selectedTeamSeason.trim()
? input.selectedTeamSeason.trim().slice(0, 30)
: null
const notificationTime = /^([01]\d|2[0-3]):[0-5]\d$/.test(String(input.notificationTime || ''))
? String(input.notificationTime)
: DEFAULT_NOTIFICATION_SETTINGS.notificationTime
return {
newNews: coerceBoolean(input.newNews),
newEvents: coerceBoolean(input.newEvents),
eventsToday: coerceBoolean(input.eventsToday),
eventsTomorrow: coerceBoolean(input.eventsTomorrow),
ownTeamMatches: coerceBoolean(input.ownTeamMatches),
allTeamMatches: coerceBoolean(input.allTeamMatches),
birthdays: coerceBoolean(input.birthdays),
newContactRequest: coerceBoolean(input.newContactRequest),
newUserRegistration: coerceBoolean(input.newUserRegistration),
selectedTeamSlugs: [...new Set(selectedTeamSlugs)],
selectedTeamSeason,
notificationTime
}
}
export function notificationSettingsForUser(user) {
return sanitizeNotificationSettings({
...DEFAULT_NOTIFICATION_SETTINGS,
...(user?.notificationSettings || user?.notifications || {})
})
}

View File

@@ -0,0 +1,190 @@
import crypto from 'crypto'
import { promises as fs } from 'fs'
import path from 'path'
import { readUsers, writeUsers, isHiddenUser } from './auth.js'
import { notificationSettingsForUser } from './notification-settings.js'
const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging'
const TOKEN_URL = 'https://oauth2.googleapis.com/token'
const tokenCache = { accessToken: null, expiresAt: 0 }
function base64Url(input) {
return Buffer.from(input).toString('base64url')
}
function projectIdFromServiceAccount(serviceAccount) {
return process.env.FCM_PROJECT_ID || serviceAccount.project_id
}
function serviceAccountCandidatePaths() {
const filename = 'harheimer-tc-firebase-adminsdk-fbsvc-18b66a2971.json'
const cwd = process.cwd()
const candidates = []
if (process.env.GOOGLE_APPLICATION_CREDENTIALS) candidates.push(process.env.GOOGLE_APPLICATION_CREDENTIALS)
candidates.push(path.join(cwd, 'server/data', filename))
candidates.push(path.join(cwd, '../server/data', filename))
return [...new Set(candidates)]
}
async function readServiceAccount() {
if (process.env.FCM_SERVICE_ACCOUNT_JSON) {
return JSON.parse(process.env.FCM_SERVICE_ACCOUNT_JSON)
}
for (const candidate of serviceAccountCandidatePaths()) {
try {
const raw = await fs.readFile(candidate, 'utf8')
return JSON.parse(raw)
} catch (error) {
if (error?.code !== 'ENOENT') {
console.warn(`FCM Service-Account konnte nicht gelesen werden (${candidate}): ${error.message}`)
}
}
}
return null
}
async function getAccessToken(serviceAccount) {
if (tokenCache.accessToken && tokenCache.expiresAt > Date.now() + 60_000) {
return tokenCache.accessToken
}
const now = Math.floor(Date.now() / 1000)
const assertion = [
base64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' })),
base64Url(JSON.stringify({
iss: serviceAccount.client_email,
scope: FCM_SCOPE,
aud: TOKEN_URL,
iat: now,
exp: now + 3600
}))
].join('.')
const signature = crypto
.createSign('RSA-SHA256')
.update(assertion)
.sign(serviceAccount.private_key, 'base64url')
const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: `${assertion}.${signature}`
})
})
if (!response.ok) {
throw new Error(`FCM OAuth fehlgeschlagen: ${response.status}`)
}
const body = await response.json()
tokenCache.accessToken = body.access_token
tokenCache.expiresAt = Date.now() + Number(body.expires_in || 3600) * 1000
return tokenCache.accessToken
}
function pushTokensForUser(user) {
return Array.isArray(user.pushTokens)
? user.pushTokens.filter(entry => entry?.token && entry.platform === 'android')
: []
}
export function upsertPushToken(user, { token, platform = 'android', appVersion = null }) {
const normalizedToken = String(token || '').trim()
if (!normalizedToken) return user
const now = new Date().toISOString()
const tokens = Array.isArray(user.pushTokens) ? user.pushTokens : []
const next = tokens.filter(entry => entry?.token !== normalizedToken)
next.push({
token: normalizedToken,
platform: String(platform || 'android').slice(0, 30),
appVersion: appVersion ? String(appVersion).slice(0, 80) : null,
updatedAt: now,
createdAt: tokens.find(entry => entry?.token === normalizedToken)?.createdAt || now
})
user.pushTokens = next.slice(-20)
return user
}
async function sendFcmMessage({ serviceAccount, accessToken, token, title, body, data = {} }) {
const projectId = projectIdFromServiceAccount(serviceAccount)
if (!projectId) throw new Error('FCM project_id fehlt.')
const response = await fetch(`https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`, {
method: 'POST',
headers: {
authorization: `Bearer ${accessToken}`,
'content-type': 'application/json'
},
body: JSON.stringify({
message: {
token,
notification: { title, body },
data,
android: {
priority: 'high',
notification: {
channel_id: 'harheimer_tc_updates',
click_action: 'OPEN_NEWS'
}
}
}
})
})
if (!response.ok) {
const text = await response.text().catch(() => '')
throw new Error(`FCM send fehlgeschlagen: ${response.status} ${text}`)
}
}
export async function sendNewNewsPush(news) {
const serviceAccount = await readServiceAccount()
if (!serviceAccount) {
console.warn('FCM nicht konfiguriert: FCM_SERVICE_ACCOUNT_JSON oder GOOGLE_APPLICATION_CREDENTIALS fehlt.')
return { sent: 0, skipped: true }
}
const accessToken = await getAccessToken(serviceAccount)
const users = await readUsers()
let sent = 0
let failed = 0
let removed = 0
let recipients = 0
let tokenCount = 0
let changed = false
const title = 'Neue News'
const body = String(news.title || 'Neue Nachricht vom Harheimer TC').slice(0, 120)
const data = {
type: 'news',
newsId: String(news.id || ''),
title,
body,
notificationId: String(news.id || Date.now()).split('').reduce((acc, char) => acc + char.charCodeAt(0), 0).toString()
}
for (const user of users) {
if (isHiddenUser(user)) continue
const settings = notificationSettingsForUser(user)
if (!settings.newNews) continue
recipients += 1
const tokens = pushTokensForUser(user)
tokenCount += tokens.length
const validTokens = []
for (const entry of tokens) {
try {
await sendFcmMessage({ serviceAccount, accessToken, token: entry.token, title, body, data })
sent += 1
validTokens.push(entry)
} catch (error) {
failed += 1
console.error('FCM News-Push fehlgeschlagen:', error.message)
if (!/UNREGISTERED|NOT_FOUND|INVALID_ARGUMENT/.test(String(error.message))) {
validTokens.push(entry)
} else {
removed += 1
changed = true
}
}
}
if (validTokens.length !== tokens.length) {
user.pushTokens = validTokens
changed = true
}
}
if (changed) await writeUsers(users)
return { sent, failed, removed, recipients, tokenCount, skipped: false }
}

View File

@@ -1,6 +1,7 @@
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
import path from 'path' import path from 'path'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import { writeDataFileWithRotation } from './data-file-rotation.js'
// Use internal server/data directory for Termine CSV to avoid writing to public/ // Use internal server/data directory for Termine CSV to avoid writing to public/
const getDataPath = (filename) => { const getDataPath = (filename) => {
@@ -89,7 +90,7 @@ export async function writeTermine(termine) {
csv += `"${escapedDatum}","${escapedUhrzeit}","${escapedTitel}","${escapedBeschreibung}","${escapedKategorie}"\n` csv += `"${escapedDatum}","${escapedUhrzeit}","${escapedTitel}","${escapedBeschreibung}","${escapedKategorie}"\n`
} }
await fs.writeFile(TERMINE_FILE, csv, 'utf-8') await writeDataFileWithRotation(TERMINE_FILE, csv, { encoding: 'utf-8' })
return true return true
} catch (error) { } catch (error) {
console.error('Fehler beim Schreiben der Termine:', error) console.error('Fehler beim Schreiben der Termine:', error)

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 911 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 911 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 873 KiB

BIN
temp/device-35433-home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 907 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 899 KiB

BIN
temp/device-38281-cms-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 899 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 KiB

BIN
temp/device-38281-cms.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 899 KiB

Some files were not shown because too many files have changed in this diff Show More